Merge branch 'MDL-59630_master' of git://github.com/dmonllao/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 5 Oct 2017 06:56:57 +0000 (14:56 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 5 Oct 2017 06:56:57 +0000 (14:56 +0800)
231 files changed:
admin/index.php
admin/search.php
admin/settings/plugins.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/classes/external.php
admin/tool/mobile/db/services.php
admin/tool/mobile/upgrade.txt
admin/tool/mobile/version.php
admin/tool/templatelibrary/classes/external.php
analytics/classes/local/indicator/base.php
analytics/classes/local/time_splitting/base.php
analytics/tests/prediction_test.php
blocks/calendar_month/block_calendar_month.php
calendar/amd/build/calendar.min.js
calendar/amd/build/calendar_threemonth.min.js
calendar/amd/build/calendar_view.min.js [new file with mode: 0644]
calendar/amd/build/crud.min.js [new file with mode: 0644]
calendar/amd/build/modal_event_form.min.js
calendar/amd/build/repository.min.js
calendar/amd/build/selectors.min.js
calendar/amd/build/summary_modal.min.js
calendar/amd/build/view_manager.min.js
calendar/amd/src/calendar.js
calendar/amd/src/calendar_threemonth.js
calendar/amd/src/calendar_view.js [new file with mode: 0644]
calendar/amd/src/crud.js [new file with mode: 0644]
calendar/amd/src/modal_event_form.js
calendar/amd/src/repository.js
calendar/amd/src/selectors.js
calendar/amd/src/summary_modal.js
calendar/amd/src/view_manager.js
calendar/classes/external/calendar_event_exporter.php
calendar/classes/external/calendar_upcoming_exporter.php [new file with mode: 0644]
calendar/classes/external/day_exporter.php
calendar/classes/external/event_exporter.php
calendar/classes/external/event_exporter_base.php
calendar/classes/external/event_icon_exporter.php
calendar/classes/external/month_exporter.php
calendar/classes/external/week_day_exporter.php
calendar/classes/local/api.php
calendar/classes/local/event/container.php
calendar/classes/local/event/data_access/event_vault.php
calendar/classes/local/event/data_access/event_vault_interface.php
calendar/classes/local/event/entities/action_event.php
calendar/classes/local/event/entities/event.php
calendar/classes/local/event/entities/event_interface.php
calendar/classes/local/event/factories/event_abstract_factory.php
calendar/classes/local/event/forms/create.php
calendar/classes/local/event/mappers/event_mapper.php
calendar/classes/local/event/proxies/coursecat_proxy.php [new file with mode: 0644]
calendar/classes/local/event/strategies/raw_event_retrieval_strategy.php
calendar/classes/local/event/strategies/raw_event_retrieval_strategy_interface.php
calendar/event.php
calendar/export.php
calendar/externallib.php
calendar/lib.php
calendar/renderer.php
calendar/templates/calendar_upcoming.mustache [new file with mode: 0644]
calendar/templates/event_item.mustache
calendar/templates/event_list.mustache
calendar/templates/event_summary_body.mustache
calendar/templates/header.mustache
calendar/templates/month_detailed.mustache
calendar/templates/month_mini.mustache
calendar/templates/upcoming_detailed.mustache [new file with mode: 0644]
calendar/tests/action_event_test.php
calendar/tests/behat/calendar.feature
calendar/tests/behat/category_events.feature [new file with mode: 0644]
calendar/tests/container_test.php
calendar/tests/coursecat_proxy_test.php [new file with mode: 0644]
calendar/tests/event_factory_test.php
calendar/tests/event_mapper_test.php
calendar/tests/event_test.php
calendar/tests/externallib_test.php
calendar/tests/helpers.php
calendar/tests/raw_event_retrieval_strategy_test.php
calendar/tests/repeat_event_collection_test.php
calendar/tests/rrule_manager_test.php
calendar/view.php
cohort/externallib.php
comment/classes/external.php
config-dist.php
enrol/meta/tests/behat/enrol_meta.feature
enrol/self/tests/behat/self_enrolment.feature
files/externallib.php
group/externallib.php
lang/en/admin.php
lang/en/calendar.php
lib/amd/build/chartjs-lazy.min.js
lib/amd/src/chartjs-lazy.js
lib/behat/classes/partial_named_selector.php
lib/classes/external/coursecat_summary_exporter.php [new file with mode: 0644]
lib/classes/output/icon_system_fontawesome.php
lib/coursecatlib.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/filestorage/file_system.php
lib/htmlpurifier/HTMLPurifier.php
lib/htmlpurifier/HTMLPurifier.safe-includes.php
lib/htmlpurifier/HTMLPurifier/AttrDef.php
lib/htmlpurifier/HTMLPurifier/AttrDef/CSS.php
lib/htmlpurifier/HTMLPurifier/AttrDef/CSS/Color.php
lib/htmlpurifier/HTMLPurifier/AttrDef/URI/Host.php
lib/htmlpurifier/HTMLPurifier/AttrTransform/TargetNoopener.php [new file with mode: 0644]
lib/htmlpurifier/HTMLPurifier/ChildDef/List.php
lib/htmlpurifier/HTMLPurifier/Config.php
lib/htmlpurifier/HTMLPurifier/ConfigSchema.php
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema.ser
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.AggressivelyRemoveScript.txt [new file with mode: 0644]
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.LegacyEntityDecoder.txt [new file with mode: 0644]
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/HTML.TargetNoopener.txt [new file with mode: 0644]
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/URI.DefaultScheme.txt
lib/htmlpurifier/HTMLPurifier/DefinitionCache/Serializer.php
lib/htmlpurifier/HTMLPurifier/DefinitionCache/Serializer/README [changed mode: 0644->0755]
lib/htmlpurifier/HTMLPurifier/Encoder.php
lib/htmlpurifier/HTMLPurifier/EntityParser.php
lib/htmlpurifier/HTMLPurifier/Filter/ExtractStyleBlocks.php
lib/htmlpurifier/HTMLPurifier/Generator.php
lib/htmlpurifier/HTMLPurifier/HTMLModule/TargetNoopener.php [new file with mode: 0644]
lib/htmlpurifier/HTMLPurifier/HTMLModuleManager.php
lib/htmlpurifier/HTMLPurifier/Lexer.php
lib/htmlpurifier/HTMLPurifier/Lexer/DOMLex.php
lib/htmlpurifier/HTMLPurifier/Lexer/DirectLex.php
lib/htmlpurifier/HTMLPurifier/Lexer/PH5P.php
lib/htmlpurifier/HTMLPurifier/Strategy/MakeWellFormed.php
lib/htmlpurifier/HTMLPurifier/Token.php
lib/htmlpurifier/HTMLPurifier/URI.php
lib/htmlpurifier/readme_moodle.txt
lib/markdown/License.md
lib/markdown/Markdown.php
lib/markdown/MarkdownExtra.php
lib/markdown/MarkdownInterface.php
lib/markdown/Readme.md
lib/markdown/readme_moodle.txt
lib/minify/matthiasmullie-minify/data/js/keywords_before.txt
lib/minify/matthiasmullie-minify/data/js/operators_after.txt
lib/minify/matthiasmullie-minify/src/CSS.php
lib/minify/matthiasmullie-minify/src/JS.php
lib/minify/matthiasmullie-minify/src/Minify.php
lib/minify/matthiasmullie-pathconverter/src/Converter.php
lib/minify/matthiasmullie-pathconverter/src/ConverterInterface.php [new file with mode: 0644]
lib/minify/matthiasmullie-pathconverter/src/NoConverter.php [new file with mode: 0644]
lib/minify/readme_moodle.txt
lib/moodlelib.php
lib/scssphp/Base/Range.php
lib/scssphp/Block.php
lib/scssphp/Colors.php
lib/scssphp/Compiler.php
lib/scssphp/Compiler/Environment.php
lib/scssphp/Exception/CompilerException.php
lib/scssphp/Exception/ParserException.php
lib/scssphp/Exception/RangeException.php [new file with mode: 0644]
lib/scssphp/Exception/ServerException.php
lib/scssphp/Formatter.php
lib/scssphp/Formatter/Compact.php
lib/scssphp/Formatter/Compressed.php
lib/scssphp/Formatter/Crunched.php
lib/scssphp/Formatter/Debug.php
lib/scssphp/Formatter/Expanded.php
lib/scssphp/Formatter/Nested.php
lib/scssphp/Formatter/OutputBlock.php
lib/scssphp/Node.php
lib/scssphp/Node/Number.php
lib/scssphp/Parser.php
lib/scssphp/Server.php
lib/scssphp/Type.php
lib/scssphp/Util.php
lib/scssphp/Version.php
lib/scssphp/moodle_readme.txt
lib/setuplib.php
lib/simplepie/LICENSE.txt [new file with mode: 0644]
lib/simplepie/autoloader.php
lib/simplepie/library/SimplePie.php
lib/simplepie/library/SimplePie/Category.php
lib/simplepie/library/SimplePie/Content/Type/Sniffer.php
lib/simplepie/library/SimplePie/File.php
lib/simplepie/library/SimplePie/Item.php
lib/simplepie/library/SimplePie/Locator.php
lib/simplepie/library/SimplePie/Misc.php
lib/simplepie/library/SimplePie/Parse/Date.php
lib/simplepie/library/SimplePie/Sanitize.php
lib/simplepie/readme_moodle.txt
lib/testing/generator/data_generator.php
lib/tests/behat/behat_data_generators.php
lib/tests/coursecatlib_test.php
lib/tests/filelib_test.php
lib/tests/htmlpurifier_test.php
lib/tests/markdown_test.php
lib/tests/moodlelib_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
media/player/vimeo/wsplayer.php [new file with mode: 0644]
message/externallib.php
message/tests/behat/delete_all_messages.feature
mod/book/lang/en/book.php
mod/forum/lang/en/forum.php
mod/glossary/lang/en/glossary.php
mod/quiz/classes/structure.php
mod/quiz/lang/en/quiz.php
mod/quiz/locallib.php
mod/quiz/tests/behat/editing_section_headings.feature
mod/quiz/tests/structure_test.php
mod/wiki/lang/en/wiki.php
mod/workshop/assessment.php
mod/workshop/classes/external.php
mod/workshop/classes/external/assessment_exporter.php [new file with mode: 0644]
mod/workshop/classes/external/submission_exporter.php [new file with mode: 0644]
mod/workshop/db/services.php
mod/workshop/form/assessment_form.php
mod/workshop/locallib.php
mod/workshop/submission.php
mod/workshop/tests/external_test.php
mod/workshop/version.php
notes/externallib.php
pix/i/categoryevent.svg [new file with mode: 0644]
question/classes/external.php
report/insights/classes/output/insight.php
report/security/lang/en/report_security.php
repository/tests/behat/cancel_add_file.feature
search/classes/manager.php
search/tests/manager_test.php
tag/classes/external.php
tags.txt [deleted file]
theme/boost/scss/moodle/calendar.scss
theme/boost/tests/behat/behat_theme_boost_behat_blocks.php
theme/bootstrapbase/less/moodle/calendar.less
theme/bootstrapbase/style/moodle.css
user/externallib.php
user/tests/behat/edit_user_enrolment.feature
version.php

index 88a7d4c..b077d44 100644 (file)
@@ -865,7 +865,7 @@ $eventshandlers = $DB->get_records_sql('SELECT DISTINCT component FROM {events_h
 $themedesignermode = !empty($CFG->themedesignermode);
 
 // Check if a directory with development libraries exists.
-if (is_dir($CFG->dirroot.'/vendor') || is_dir($CFG->dirroot.'/node_modules')) {
+if (empty($CFG->disabledevlibdirscheck) && (is_dir($CFG->dirroot.'/vendor') || is_dir($CFG->dirroot.'/node_modules'))) {
     $devlibdir = true;
 } else {
     $devlibdir = false;
index fb38431..623cf1c 100644 (file)
@@ -5,11 +5,19 @@
 require_once('../config.php');
 require_once($CFG->libdir.'/adminlib.php');
 
+redirect_if_major_upgrade_required();
+
 $query = trim(optional_param('query', '', PARAM_NOTAGS));  // Search string
 
 $context = context_system::instance();
 $PAGE->set_context($context);
 
+$hassiteconfig = has_capability('moodle/site:config', $context);
+
+if ($hassiteconfig && moodle_needs_upgrading()) {
+    redirect(new moodle_url('/admin/index.php'));
+}
+
 admin_externalpage_setup('search', '', array('query' => $query)); // now hidden page
 
 $adminroot = admin_get_root(); // need all settings here
@@ -55,7 +63,7 @@ if ($errormsg !== '') {
 
 $showsettingslinks = true;
 
-if (has_capability('moodle/site:config', $context)) {
+if ($hassiteconfig) {
     require_once("admin_settings_search_form.php");
     $form = new admin_settings_search_form();
     $form->display();
index 3bb3c4a..4444ff0 100644 (file)
@@ -557,13 +557,21 @@ if ($hassiteconfig) {
     $temp->add(new admin_setting_heading('searchengineheading', new lang_string('searchengine', 'admin'), ''));
     $temp->add(new admin_setting_configselect('searchengine',
                                 new lang_string('selectsearchengine', 'admin'), '', 'solr', $engines));
-    $temp->add(new admin_setting_heading('searchindexingheading', new lang_string('searchoptions', 'admin'), ''));
+    $temp->add(new admin_setting_heading('searchoptionsheading', new lang_string('searchoptions', 'admin'), ''));
     $temp->add(new admin_setting_configcheckbox('searchindexwhendisabled',
             new lang_string('searchindexwhendisabled', 'admin'), new lang_string('searchindexwhendisabled_desc', 'admin'),
             0));
     $temp->add(new admin_setting_configduration('searchindextime',
             new lang_string('searchindextime', 'admin'), new lang_string('searchindextime_desc', 'admin'),
             600));
+    $options = [
+        0 => new lang_string('searchallavailablecourses_off', 'admin'),
+        1 => new lang_string('searchallavailablecourses_on', 'admin')
+    ];
+    $temp->add(new admin_setting_configselect('searchallavailablecourses',
+            new lang_string('searchallavailablecourses', 'admin'),
+            new lang_string('searchallavailablecourses_desc', 'admin'),
+            0, $options));
 
     $ADMIN->add('searchplugins', $temp);
     $ADMIN->add('searchplugins', new admin_externalpage('searchareas', new lang_string('searchareas', 'admin'),
index 7d1674a..493b7da 100644 (file)
@@ -58,15 +58,26 @@ class api {
         global $CFG;
         require_once($CFG->libdir . '/adminlib.php');
 
+        $cachekey = 'mobileplugins';
+        if (!isloggedin()) {
+            $cachekey = 'authmobileplugins';    // Use a different cache for not logged users.
+        }
+
         // Check if we can return this from cache.
         $cache = \cache::make('tool_mobile', 'plugininfo');
-        $pluginsinfo = $cache->get('mobileplugins');
+        $pluginsinfo = $cache->get($cachekey);
         if ($pluginsinfo !== false) {
             return (array)$pluginsinfo;
         }
 
         $pluginsinfo = [];
-        $plugintypes = core_component::get_plugin_types();
+        // For not logged users return only auth plugins.
+        // This is to avoid anyone (not being a registered user) to obtain and download all the site remote add-ons.
+        if (!isloggedin()) {
+            $plugintypes = array('auth' => $CFG->dirroot.'/auth');
+        } else {
+            $plugintypes = core_component::get_plugin_types();
+        }
 
         foreach ($plugintypes as $plugintype => $unused) {
             // We need to include files here.
@@ -100,7 +111,7 @@ class api {
             }
         }
 
-        $cache->set('mobileplugins', $pluginsinfo);
+        $cache->set($cachekey, $pluginsinfo);
 
         return $pluginsinfo;
     }
index 3c60740..d54d748 100644 (file)
@@ -23,6 +23,7 @@
  */
 
 namespace tool_mobile;
+defined('MOODLE_INTERNAL') || die();
 
 require_once("$CFG->libdir/externallib.php");
 
index 9a22aa8..aa64cc7 100644 (file)
@@ -30,7 +30,9 @@ $functions = array(
         'methodname'  => 'get_plugins_supporting_mobile',
         'description' => 'Returns a list of Moodle plugins supporting the mobile app.',
         'type'        => 'read',
-        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
+        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+        'ajax'          => true,
+        'loginrequired' => false,
     ),
 
     'tool_mobile_get_public_config' => array(
index 9a93520..6c2c704 100644 (file)
@@ -1,6 +1,11 @@
 This files describes changes in tool_mobile code.
 Information provided here is intended especially for developers.
 
+=== 3.4 ===
+
+ * External function tool_mobile::tool_mobile_get_plugins_supporting_mobile is now available via AJAX for not logged users.
+   When called via AJAX without a user session the function will return only auth plugins.
+
 === 3.3 ===
 
  * External function tool_mobile::get_public_config now returns the mobilecssurl field (Mobile custom CSS theme).
index 0903cc5..34163ae 100644 (file)
@@ -23,7 +23,7 @@
  */
 
 defined('MOODLE_INTERNAL') || die();
-$plugin->version   = 2017051500; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2017051501; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2017050500; // Requires this Moodle version.
 $plugin->component = 'tool_mobile'; // Full name of the plugin (used for diagnostics).
 $plugin->dependencies = array(
index b92f608..f1c72f5 100644 (file)
@@ -22,6 +22,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 namespace tool_templatelibrary;
+defined('MOODLE_INTERNAL') || die();
 
 require_once("$CFG->libdir/externallib.php");
 
index 7c6aa3a..5cd88b9 100644 (file)
@@ -147,7 +147,7 @@ abstract class base extends \core_analytics\calculable {
      * @param integer $starttime Limit the calculation to this timestart
      * @param integer $endtime Limit the calculation to this timeend
      * @param array $existingcalculations Existing calculations of this indicator, indexed by sampleid.
-     * @return array array[0] with format [sampleid] = int[]|float[], array[1] with format [sampleid] = int|float
+     * @return array [0] = [$sampleid => int[]|float[]], [1] = [$sampleid => int|float], [2] = [$sampleid => $sampleid]
      */
     public function calculate($sampleids, $samplesorigin, $starttime = false, $endtime = false, $existingcalculations = array()) {
 
@@ -157,6 +157,7 @@ abstract class base extends \core_analytics\calculable {
 
         $calculations = array();
         $newcalculations = array();
+        $notnulls = array();
         foreach ($sampleids as $sampleid => $unusedsampleid) {
 
             if (isset($existingcalculations[$sampleid])) {
@@ -166,9 +167,12 @@ abstract class base extends \core_analytics\calculable {
                 $newcalculations[$sampleid] = $calculatedvalue;
             }
 
-            if (!is_null($calculatedvalue) && ($calculatedvalue > self::MAX_VALUE || $calculatedvalue < self::MIN_VALUE)) {
-                throw new \coding_exception('Calculated values should be higher than ' . self::MIN_VALUE .
-                    ' and lower than ' . self::MAX_VALUE . ' ' . $calculatedvalue . ' received');
+            if (!is_null($calculatedvalue)) {
+                $notnulls[$sampleid] = $sampleid;
+                if ($calculatedvalue > self::MAX_VALUE || $calculatedvalue < self::MIN_VALUE) {
+                    throw new \coding_exception('Calculated values should be higher than ' . self::MIN_VALUE .
+                        ' and lower than ' . self::MAX_VALUE . ' ' . $calculatedvalue . ' received');
+                }
             }
 
             $calculations[$sampleid] = $calculatedvalue;
@@ -176,6 +180,6 @@ abstract class base extends \core_analytics\calculable {
 
         $features = $this->to_features($calculations);
 
-        return array($features, $newcalculations);
+        return array($features, $newcalculations, $notnulls);
     }
 }
index f45934a..11532a9 100644 (file)
@@ -231,6 +231,9 @@ abstract class base {
                 $range['start'], $range['end'], $samplesorigin);
         }
 
+        // Here we store samples which calculations are not all null.
+        $notnulls = array();
+
         // Fill the dataset samples with indicators data.
         $newcalculations = array();
         foreach ($indicators as $indicator) {
@@ -250,7 +253,7 @@ abstract class base {
                 }
 
                 // Calculate the indicator for each sample in this time range.
-                list($samplesfeatures, $newindicatorcalculations) = $rangeindicator->calculate($sampleids,
+                list($samplesfeatures, $newindicatorcalculations, $indicatornotnulls) = $rangeindicator->calculate($sampleids,
                     $samplesorigin, $range['start'], $range['end'], $prevcalculations);
 
                 // Copy the features data to the dataset.
@@ -258,6 +261,10 @@ abstract class base {
 
                     $uniquesampleid = $this->append_rangeindex($analysersampleid, $rangeindex);
 
+                    if (!isset($notnulls[$uniquesampleid]) && !empty($indicatornotnulls[$analysersampleid])) {
+                        $notnulls[$uniquesampleid] = $uniquesampleid;
+                    }
+
                     // Init the sample if it is still empty.
                     if (!isset($dataset[$uniquesampleid])) {
                         $dataset[$uniquesampleid] = array();
@@ -307,6 +314,15 @@ abstract class base {
             $DB->insert_records('analytics_indicator_calc', $newcalculations);
         }
 
+        // Delete rows where all calculations are null.
+        // We still store the indicator calculation and we still store the sample id as
+        // processed so we don't have to process this sample again, but we exclude it
+        // from the dataset because it is not useful.
+        $nulls = array_diff_key($dataset, $notnulls);
+        foreach ($nulls as $uniqueid => $ignoredvalues) {
+            unset($dataset[$uniqueid]);
+        }
+
         return $dataset;
     }
 
index 1e21d8d..7f30037 100644 (file)
@@ -361,6 +361,57 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         list($values, $unused) = $indicator->calculate($sampleids, $sampleorigin, $starttime, $endtime, $existingcalcs);
     }
 
+    /**
+     * test_not_null_samples
+     */
+    public function test_not_null_samples() {
+        $this->resetAfterTest(true);
+
+        $classname = '\core\analytics\time_splitting\quarters';
+        $timesplitting = \core_analytics\manager::get_time_splitting($classname);
+        $timesplitting->set_analysable(new \core_analytics\site());
+
+        $ranges = array(
+            array('start' => 111, 'end' => 222, 'time' => 222),
+            array('start' => 222, 'end' => 333, 'time' => 333)
+        );
+        $samples = array(123 => 123, 321 => 321);
+
+        $indicator1 = $this->getMockBuilder('test_indicator_max')
+            ->setMethods(['calculate_sample'])
+            ->getMock();
+        $indicator1->method('calculate_sample')
+            ->willReturn(null);
+
+        $indicator2 = \core_analytics\manager::get_indicator('test_indicator_min');
+
+        // Samples with at least 1 not null value are returned.
+        $params = array(
+            $samples,
+            'whatever',
+            array($indicator1, $indicator2),
+            $ranges
+        );
+        $dataset = phpunit_util::call_internal_method($timesplitting, 'calculate_indicators', $params, $classname);
+        $this->assertArrayHasKey('123-0', $dataset);
+        $this->assertArrayHasKey('123-1', $dataset);
+        $this->assertArrayHasKey('321-0', $dataset);
+        $this->assertArrayHasKey('321-1', $dataset);
+
+        // Samples with only null values are not returned.
+        $params = array(
+            $samples,
+            'whatever',
+            array($indicator1),
+            $ranges
+        );
+        $dataset = phpunit_util::call_internal_method($timesplitting, 'calculate_indicators', $params, $classname);
+        $this->assertArrayNotHasKey('123-0', $dataset);
+        $this->assertArrayNotHasKey('123-1', $dataset);
+        $this->assertArrayNotHasKey('321-0', $dataset);
+        $this->assertArrayNotHasKey('321-1', $dataset);
+    }
+
     /**
      * provider_ml_test_evaluation
      *
index 18a57d7..0825798 100644 (file)
@@ -38,40 +38,37 @@ class block_calendar_month extends block_base {
     public function get_content() {
         global $CFG;
 
-        $calm = optional_param('cal_m', 0, PARAM_INT);
-        $caly = optional_param('cal_y', 0, PARAM_INT);
-        $time = optional_param('time', 0, PARAM_INT);
-
         require_once($CFG->dirroot.'/calendar/lib.php');
 
         if ($this->content !== null) {
             return $this->content;
         }
 
-        // If a day, month and year were passed then convert it to a timestamp. If these were passed then we can assume
-        // the day, month and year are passed as Gregorian, as no where in core should we be passing these values rather
-        // than the time. This is done for BC.
-        if (!empty($calm) && (!empty($caly))) {
-            $time = make_timestamp($caly, $calm, 1);
-        } else if (empty($time)) {
-            $time = time();
-        }
-
         $this->content = new stdClass;
         $this->content->text = '';
         $this->content->footer = '';
 
-        // [pj] To me it looks like this if would never be needed, but Penny added it
-        // when committing the /my/ stuff. Reminder to discuss and learn what it's about.
-        // It definitely needs SOME comment here!
         $courseid = $this->page->course->id;
         $issite = ($courseid == SITEID);
 
+        $course = null;
+        $courses = null;
+        $categories = null;
+
         if ($issite) {
             // Being displayed at site level. This will cause the filter to fall back to auto-detecting
             // the list of courses it will be grabbing events from.
             $course = get_site();
             $courses = calendar_get_default_courses();
+
+            if ($this->page->context->contextlevel === CONTEXT_COURSECAT) {
+                // Restrict to categories, and their parents, and the courses that the user is enrolled in within those
+                // categories.
+                $categories = array_keys($this->page->categories);
+                $courses = array_filter($courses, function($course) use ($categories) {
+                    return array_search($course->category, $categories) !== false;
+                });
+            }
         } else {
             // Forcibly filter events to include only those from the particular course we are in.
             $course = $this->page->course;
@@ -80,8 +77,8 @@ class block_calendar_month extends block_base {
 
         $renderer = $this->page->get_renderer('core_calendar');
 
-        $calendar = new calendar_information(0, 0, 0, $time);
-        $calendar->prepare_for_view($course, $courses);
+        $calendar = new calendar_information();
+        $calendar->set_sources($course, $courses, $this->page->category);
 
         list($data, $template) = calendar_get_view($calendar, 'mini');
         $this->content->text .= $renderer->render_from_template($template, $data);
index a1c8e36..da91072 100644 (file)
Binary files a/calendar/amd/build/calendar.min.js and b/calendar/amd/build/calendar.min.js differ
index ac1bb8c..f0300b1 100644 (file)
Binary files a/calendar/amd/build/calendar_threemonth.min.js and b/calendar/amd/build/calendar_threemonth.min.js differ
diff --git a/calendar/amd/build/calendar_view.min.js b/calendar/amd/build/calendar_view.min.js
new file mode 100644 (file)
index 0000000..c29216c
Binary files /dev/null and b/calendar/amd/build/calendar_view.min.js differ
diff --git a/calendar/amd/build/crud.min.js b/calendar/amd/build/crud.min.js
new file mode 100644 (file)
index 0000000..7ba0606
Binary files /dev/null and b/calendar/amd/build/crud.min.js differ
index 4d52804..5b5ef99 100644 (file)
Binary files a/calendar/amd/build/modal_event_form.min.js and b/calendar/amd/build/modal_event_form.min.js differ
index c970b3f..2d393c8 100644 (file)
Binary files a/calendar/amd/build/repository.min.js and b/calendar/amd/build/repository.min.js differ
index a81f332..303217e 100644 (file)
Binary files a/calendar/amd/build/selectors.min.js and b/calendar/amd/build/selectors.min.js differ
index fdca2a5..f13ba37 100644 (file)
Binary files a/calendar/amd/build/summary_modal.min.js and b/calendar/amd/build/summary_modal.min.js differ
index 61f7942..a7dbfc2 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js and b/calendar/amd/build/view_manager.min.js differ
index 0c436cc..4f10cb0 100644 (file)
@@ -38,7 +38,9 @@ define([
             'core_calendar/summary_modal',
             'core_calendar/repository',
             'core_calendar/events',
-            'core_calendar/view_manager'
+            'core_calendar/view_manager',
+            'core_calendar/crud',
+            'core_calendar/selectors',
         ],
         function(
             $,
@@ -53,7 +55,9 @@ define([
             SummaryModal,
             CalendarRepository,
             CalendarEvents,
-            CalendarViewManager
+            CalendarViewManager,
+            CalendarCrud,
+            CalendarSelectors
         ) {
 
     var SELECTORS = {
@@ -192,29 +196,6 @@ define([
         }
     };
 
-    /**
-     * Create the event form modal for creating new events and
-     * editing existing events.
-     *
-     * @method registerEventFormModal
-     * @param {object} root The calendar root element
-     * @return {object} The create modal promise
-     */
-    var registerEventFormModal = function(root) {
-        var newEventButton = root.find(SELECTORS.NEW_EVENT_BUTTON);
-        var contextId = newEventButton.attr('data-context-id');
-
-        return ModalFactory.create(
-            {
-                type: ModalEventForm.TYPE,
-                large: true,
-                templateContext: {
-                    contextid: contextId
-                }
-            }
-        );
-    };
-
     /**
      * Listen to and handle any calendar events fired by the calendar UI.
      *
@@ -223,8 +204,7 @@ define([
      * @param {object} eventFormModalPromise A promise reolved with the event form modal
      */
     var registerCalendarEventListeners = function(root, eventFormModalPromise) {
-        var body = $('body'),
-            courseId = $(root).find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid');
+        var body = $('body');
 
         body.on(CalendarEvents.created, function() {
             CalendarViewManager.reloadCurrentMonth(root);
@@ -246,14 +226,16 @@ define([
             CalendarViewManager.reloadCurrentMonth(root);
         });
 
-        eventFormModalPromise.then(function(modal) {
+        eventFormModalPromise
+        .then(function(modal) {
             // When something within the calendar tells us the user wants
             // to edit an event then show the event form modal.
             body.on(CalendarEvents.editEvent, function(e, eventId) {
+                var calendarWrapper = root.find(CalendarSelectors.wrapper);
                 modal.setEventId(eventId);
+                modal.setContextId(calendarWrapper.data('contextId'));
                 modal.show();
             });
-            modal.setCourseId(courseId);
             return;
         })
         .fail(Notification.exception);
@@ -287,7 +269,7 @@ define([
         root.on('change', SELECTORS.COURSE_SELECTOR, function() {
             var selectElement = $(this);
             var courseId = selectElement.val();
-            CalendarViewManager.reloadCurrentMonth(root, courseId)
+            CalendarViewManager.reloadCurrentMonth(root, courseId, null)
                 .then(function() {
                     // We need to get the selector again because the content has changed.
                     return root.find(SELECTORS.COURSE_SELECTOR).val(courseId);
@@ -295,34 +277,26 @@ define([
                 .fail(Notification.exception);
         });
 
-        var eventFormPromise = registerEventFormModal(root);
+        var eventFormPromise = CalendarCrud.registerEventFormModal(root);
         registerCalendarEventListeners(root, eventFormPromise);
 
-        // Bind click event on the new event button.
-        root.on('click', SELECTORS.NEW_EVENT_BUTTON, function(e) {
-            eventFormPromise.then(function(modal) {
-                // Attempt to find the cell for today.
-                // If it can't be found, then use the start time of the first day on the calendar.
-                var today = root.find(SELECTORS.TODAY);
-                if (!today.length) {
-                    modal.setStartTime(root.find(SELECTORS.DAY).attr('data-new-event-timestamp'));
-                }
-
-                modal.show();
-                return;
-            })
-            .fail(Notification.exception);
-
-            e.preventDefault();
-        });
-
         // Bind click events to calendar days.
         root.on('click', SELECTORS.DAY, function(e) {
+
             var target = $(e.target);
 
             if (!target.is(SELECTORS.VIEW_DAY_LINK)) {
                 var startTime = $(this).attr('data-new-event-timestamp');
                 eventFormPromise.then(function(modal) {
+                    var wrapper = target.closest(CalendarSelectors.wrapper);
+                    modal.setCourseId(wrapper.data('courseid'));
+
+                    var categoryId = wrapper.data('categoryid');
+                    if (typeof categoryId !== 'undefined') {
+                        modal.setCategoryId(categoryId);
+                    }
+
+                    modal.setContextId(wrapper.data('contextId'));
                     modal.setStartTime(startTime);
                     modal.show();
                     return;
@@ -337,7 +311,6 @@ define([
     return {
         init: function(root) {
             root = $(root);
-
             CalendarViewManager.init(root);
             registerEventListeners(root);
         }
index a043887..3743d4b 100644 (file)
@@ -24,6 +24,7 @@
  */
 define([
     'jquery',
+    'core/notification',
     'core_calendar/selectors',
     'core_calendar/events',
     'core/templates',
@@ -31,6 +32,7 @@ define([
 ],
 function(
     $,
+    Notification,
     CalendarSelectors,
     CalendarEvents,
     Templates,
@@ -45,18 +47,20 @@ function(
      */
     var registerCalendarEventListeners = function(root) {
         var body = $('body');
-        body.on(CalendarEvents.monthChanged, function(e, year, month, courseId) {
+        body.on(CalendarEvents.monthChanged, function(e, year, month, courseId, categoryId) {
             // We have to use a queue here because the calling code is decoupled from these listeners.
             // It's possible for the event to be called multiple times before one call is fully resolved.
             root.queue(function(next) {
-                return processRequest(e, year, month, courseId)
+                return processRequest(e, year, month, courseId, categoryId)
                 .then(function() {
                     return next();
-                });
+                })
+                .fail(Notification.exception)
+                ;
             });
         });
 
-        var processRequest = function(e, year, month, courseId) {
+        var processRequest = function(e, year, month, courseId, categoryId) {
             var newCurrentMonth = root.find('[data-year="' + year + '"][data-month="' + month + '"]');
             var newParent = newCurrentMonth.closest(CalendarSelectors.calendarPeriods.month);
             var allMonths = root.find(CalendarSelectors.calendarPeriods.month);
@@ -95,6 +99,7 @@ function(
                 requestYear,
                 requestMonth,
                 courseId,
+                categoryId,
                 placeHolder
             )
             .then(function() {
diff --git a/calendar/amd/src/calendar_view.js b/calendar/amd/src/calendar_view.js
new file mode 100644 (file)
index 0000000..d3ae3eb
--- /dev/null
@@ -0,0 +1,98 @@
+// 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 module is responsible for handle calendar day and upcoming view.
+ *
+ * @module     core_calendar/calendar
+ * @package    core_calendar
+ * @copyright  2017 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+        'jquery',
+        'core/str',
+        'core/notification',
+        'core_calendar/selectors',
+        'core_calendar/events',
+        'core_calendar/view_manager',
+        'core_calendar/repository',
+        'core/modal_factory',
+        'core_calendar/modal_event_form',
+        'core/modal_events',
+        'core_calendar/crud'
+    ],
+    function(
+        $,
+        Str,
+        Notification,
+        CalendarSelectors,
+        CalendarEvents,
+        CalendarViewManager,
+        CalendarRepository,
+        ModalFactory,
+        ModalEventForm,
+        ModalEvents,
+        CalendarCrud
+    ) {
+
+        var registerEventListeners = function(root, type) {
+            var body = $('body');
+
+            CalendarCrud.registerEventFormModal(root);
+            CalendarCrud.registerRemove(root);
+
+            var reloadFunction = 'reloadCurrent' + type.charAt(0).toUpperCase() + type.slice(1);
+
+            body.on(CalendarEvents.created, function() {
+                CalendarViewManager[reloadFunction](root);
+            });
+            body.on(CalendarEvents.deleted, function() {
+                CalendarViewManager[reloadFunction](root);
+            });
+            body.on(CalendarEvents.updated, function() {
+                CalendarViewManager[reloadFunction](root);
+            });
+
+            root.on('change', CalendarSelectors.courseSelector, function() {
+                var selectElement = $(this);
+                var courseId = selectElement.val();
+                CalendarViewManager[reloadFunction](root, courseId)
+                    .then(function() {
+                        // We need to get the selector again because the content has changed.
+                        return root.find(CalendarSelectors.courseSelector).val(courseId);
+                    })
+                    .fail(Notification.exception);
+            });
+
+            body.on(CalendarEvents.filterChanged, function(e, data) {
+                var daysWithEvent = root.find(CalendarSelectors.eventType[data.type]);
+                if (data.hidden == true) {
+                    daysWithEvent.addClass('hidden');
+                } else {
+                    daysWithEvent.removeClass('hidden');
+                }
+            });
+        };
+
+        return {
+            init: function(root, type) {
+                root = $(root);
+
+                CalendarViewManager.init(root, type);
+                registerEventListeners(root, type);
+            }
+        };
+    });
diff --git a/calendar/amd/src/crud.js b/calendar/amd/src/crud.js
new file mode 100644 (file)
index 0000000..d05afb7
--- /dev/null
@@ -0,0 +1,230 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A module to handle CRUD operations within the UI.
+ *
+ * @module     core_calendar/crud
+ * @package    core_calendar
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+    'jquery',
+    'core/str',
+    'core/notification',
+    'core/custom_interaction_events',
+    'core/modal',
+    'core/modal_registry',
+    'core/modal_factory',
+    'core/modal_events',
+    'core_calendar/modal_event_form',
+    'core_calendar/repository',
+    'core_calendar/events',
+    'core_calendar/modal_delete',
+    'core_calendar/selectors',
+],
+function(
+    $,
+    Str,
+    Notification,
+    CustomEvents,
+    Modal,
+    ModalRegistry,
+    ModalFactory,
+    ModalEvents,
+    ModalEventForm,
+    CalendarRepository,
+    CalendarEvents,
+    ModalDelete,
+    CalendarSelectors
+) {
+
+    /**
+     * Prepares the action for the summary modal's delete action.
+     *
+     * @param {Number} eventId The ID of the event.
+     * @param {string} eventTitle The event title.
+     * @param {Number} eventCount The number of events in the series.
+     * @return {Promise}
+     */
+    function confirmDeletion(eventId, eventTitle, eventCount) {
+        var deleteStrings = [
+            {
+                key: 'deleteevent',
+                component: 'calendar'
+            },
+        ];
+
+        eventCount = parseInt(eventCount, 10);
+        var deletePromise;
+        var isRepeatedEvent = eventCount > 1;
+        if (isRepeatedEvent) {
+            deleteStrings.push({
+                key: 'confirmeventseriesdelete',
+                component: 'calendar',
+                param: {
+                    name: eventTitle,
+                    count: eventCount,
+                },
+            });
+
+            deletePromise = ModalFactory.create(
+                {
+                    type: ModalDelete.TYPE
+                }
+            );
+        } else {
+            deleteStrings.push({
+                key: 'confirmeventdelete',
+                component: 'calendar',
+                param: eventTitle
+            });
+
+
+            deletePromise = ModalFactory.create(
+                {
+                    type: ModalFactory.types.SAVE_CANCEL
+                }
+            );
+        }
+
+        deletePromise.then(function(deleteModal) {
+            deleteModal.show();
+
+            return;
+        })
+        .fail(Notification.exception);
+
+        var stringsPromise = Str.get_strings(deleteStrings);
+
+        var finalPromise = $.when(stringsPromise, deletePromise)
+        .then(function(strings, deleteModal) {
+            deleteModal.setTitle(strings[0]);
+            deleteModal.setBody(strings[1]);
+            if (!isRepeatedEvent) {
+                deleteModal.setSaveButtonText(strings[0]);
+            }
+
+            deleteModal.getRoot().on(ModalEvents.save, function() {
+                CalendarRepository.deleteEvent(eventId, false)
+                    .then(function() {
+                        $('body').trigger(CalendarEvents.deleted, [eventId, false]);
+                        return;
+                    })
+                    .catch(Notification.exception);
+            });
+
+            deleteModal.getRoot().on(CalendarEvents.deleteAll, function() {
+                CalendarRepository.deleteEvent(eventId, true)
+                    .then(function() {
+                        $('body').trigger(CalendarEvents.deleted, [eventId, true]);
+                        return;
+                    })
+                    .catch(Notification.exception);
+            });
+
+            return deleteModal;
+        })
+        .fail(Notification.exception);
+
+        return finalPromise;
+    }
+
+    /**
+     * Create the event form modal for creating new events and
+     * editing existing events.
+     *
+     * @method registerEventFormModal
+     * @param {object} root The calendar root element
+     * @return {object} The create modal promise
+     */
+    var registerEventFormModal = function(root) {
+        var eventFormPromise = ModalFactory.create({
+            type: ModalEventForm.TYPE,
+            large: true
+        });
+
+        // Bind click event on the new event button.
+        root.on('click', CalendarSelectors.actions.create, function(e) {
+            eventFormPromise.then(function(modal) {
+                var wrapper = root.find(CalendarSelectors.wrapper);
+
+                var categoryId = wrapper.data('categoryid');
+                if (typeof categoryId !== 'undefined') {
+                    modal.setCategoryId(categoryId);
+                }
+
+                // Attempt to find the cell for today.
+                // If it can't be found, then use the start time of the first day on the calendar.
+                var today = root.find(CalendarSelectors.today);
+                var firstDay = root.find(CalendarSelectors.day);
+                if (!today.length && firstDay.length) {
+                    modal.setStartTime(firstDay.data('newEventTimestamp'));
+                }
+
+                modal.setContextId(wrapper.data('contextId'));
+                modal.setCourseId(wrapper.data('courseid'));
+                modal.show();
+                return;
+            })
+            .fail(Notification.exception);
+
+            e.preventDefault();
+        });
+
+        root.on('click', CalendarSelectors.actions.edit, function(e) {
+            e.preventDefault();
+            var target = $(e.currentTarget),
+                calendarWrapper = target.closest(CalendarSelectors.wrapper),
+                eventWrapper = target.closest(CalendarSelectors.eventItem);
+
+            eventFormPromise.then(function(modal) {
+                // When something within the calendar tells us the user wants
+                // to edit an event then show the event form modal.
+                modal.setEventId(eventWrapper.data('eventId'));
+
+                modal.setContextId(calendarWrapper.data('contextId'));
+                modal.show();
+                return;
+            }).fail(Notification.exception);
+        });
+
+
+        return eventFormPromise;
+    };
+    /**
+     * Register the listeners required to remove the event.
+     *
+     * @param   {jQuery} root
+     */
+    function registerRemove(root) {
+        root.on('click', CalendarSelectors.actions.remove, function(e) {
+            // Fetch the event title, count, and pass them into the new dialogue.
+            var eventSource = $(this).closest(CalendarSelectors.eventItem);
+            var eventId = eventSource.data('eventId'),
+                eventTitle = eventSource.data('eventTitle'),
+                eventCount = eventSource.data('eventCount');
+            confirmDeletion(eventId, eventTitle, eventCount);
+
+            e.preventDefault();
+        });
+    }
+
+    return {
+        registerRemove: registerRemove,
+        registerEventFormModal: registerEventFormModal
+    };
+});
index 24f7b84..78d6a15 100644 (file)
@@ -65,6 +65,8 @@ define([
         this.eventId = null;
         this.startTime = null;
         this.courseId = null;
+        this.categoryId = null;
+        this.contextId = null;
         this.reloadingBody = false;
         this.reloadingTitle = false;
         this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON);
@@ -74,6 +76,26 @@ define([
     ModalEventForm.prototype = Object.create(Modal.prototype);
     ModalEventForm.prototype.constructor = ModalEventForm;
 
+    /**
+     * Set the context id to the given value.
+     *
+     * @method setContextId
+     * @param {Number} id The event id
+     */
+    ModalEventForm.prototype.setContextId = function(id) {
+        this.contextId = id;
+    };
+
+    /**
+     * Retrieve the current context id, if any.
+     *
+     * @method getContextId
+     * @return {Number|null} The event id
+     */
+    ModalEventForm.prototype.getContextId = function() {
+        return this.contextId;
+    };
+
     /**
      * Set the course id to the given value.
      *
@@ -94,6 +116,26 @@ define([
         return this.courseId;
     };
 
+    /**
+     * Set the category id to the given value.
+     *
+     * @method setCategoryId
+     * @param {int} id The event id
+     */
+    ModalEventForm.prototype.setCategoryId = function(id) {
+        this.categoryId = id;
+    };
+
+    /**
+     * Retrieve the current category id, if any.
+     *
+     * @method getCategoryId
+     * @return {int|null} The event id
+     */
+    ModalEventForm.prototype.getCategoryId = function() {
+        return this.categoryId;
+    };
+
     /**
      * Check if the modal has an course id.
      *
@@ -104,6 +146,16 @@ define([
         return this.courseId !== null;
     };
 
+    /**
+     * Check if the modal has an category id.
+     *
+     * @method hasCategoryId
+     * @return {bool}
+     */
+    ModalEventForm.prototype.hasCategoryId = function() {
+        return this.categoryId !== null;
+    };
+
     /**
      * Set the event id to the given value.
      *
@@ -220,7 +272,8 @@ define([
         .always(function() {
             this.reloadingTitle = false;
             return;
-        }.bind(this));
+        }.bind(this))
+        .fail(Notification.exception);
 
         return this.titlePromise;
     };
@@ -246,7 +299,6 @@ define([
         this.reloadingBody = true;
         this.disableButtons();
 
-        var contextId = this.saveButton.attr('data-context-id');
         var args = {};
 
         if (this.hasEventId()) {
@@ -261,11 +313,15 @@ define([
             args.courseid = this.getCourseId();
         }
 
+        if (this.hasCategoryId()) {
+            args.categoryid = this.getCategoryId();
+        }
+
         if (typeof formData !== 'undefined') {
             args.formdata = formData;
         }
 
-        this.bodyPromise = Fragment.loadFragment('calendar', 'event_form', contextId, args);
+        this.bodyPromise = Fragment.loadFragment('calendar', 'event_form', this.getContextId(), args);
 
         this.setBody(this.bodyPromise);
 
@@ -273,11 +329,12 @@ define([
             this.enableButtons();
             return;
         }.bind(this))
-        .catch(Notification.exception)
+        .fail(Notification.exception)
         .always(function() {
             this.reloadingBody = false;
             return;
-        }.bind(this));
+        }.bind(this))
+        .fail(Notification.exception);
 
         return this.bodyPromise;
     };
@@ -321,6 +378,8 @@ define([
         Modal.prototype.hide.call(this);
         this.setEventId(null);
         this.setStartTime(null);
+        this.setCourseId(null);
+        this.setCategoryId(null);
     };
 
     /**
@@ -361,7 +420,8 @@ define([
                     // If there was a server side validation error then
                     // we need to re-request the rendered form from the server
                     // in order to display the error for the user.
-                    return this.reloadBodyContent(formData);
+                    this.reloadBodyContent(formData);
+                    return;
                 } else {
                     // No problemo! Our work here is done.
                     this.hide();
@@ -382,8 +442,10 @@ define([
                 // the loading icon and re-enable the buttons.
                 loadingContainer.addClass('hidden');
                 this.enableButtons();
+
+                return;
             }.bind(this))
-            .catch(Notification.exception);
+            .fail(Notification.exception);
     };
 
     /**
index 5e1321c..a5951e8 100644 (file)
@@ -29,7 +29,7 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
      *
      * @method deleteEvent
      * @param {int} eventId The event id.
-     * @arapm {bool} deleteSeries Whether to delete all events in the series
+     * @param {bool} deleteSeries Whether to delete all events in the series
      * @return {promise} Resolved with requested calendar event
      */
     var deleteEvent = function(eventId, deleteSeries) {
@@ -93,16 +93,18 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
      * @param {Number} year Year
      * @param {Number} month Month
      * @param {Number} courseid The course id.
+     * @param {Number} categoryid The category id.
      * @param {Bool} includenavigation Whether to include navigation.
      * @return {promise} Resolved with the month view data.
      */
-    var getCalendarMonthData = function(year, month, courseid, includenavigation) {
+    var getCalendarMonthData = function(year, month, courseid, categoryid, includenavigation) {
         var request = {
             methodname: 'core_calendar_get_calendar_monthly_view',
             args: {
                 year: year,
                 month: month,
                 courseid: courseid,
+                categoryid: categoryid,
                 includenavigation: includenavigation,
             }
         };
@@ -131,11 +133,30 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
         return Ajax.call([request])[0];
     };
 
+    /**
+     * Get calendar upcoming data.
+     *
+     * @method getCalendarUpcomingData
+     * @param {Number} courseid The course id.
+     * @return {promise} Resolved with the month view data.
+     */
+    var getCalendarUpcomingData = function(courseid) {
+        var request = {
+            methodname: 'core_calendar_get_calendar_upcoming_view',
+            args: {
+                courseid: courseid,
+            }
+        };
+
+        return Ajax.call([request])[0];
+    };
+
     return {
         getEventById: getEventById,
         deleteEvent: deleteEvent,
         updateEventStartDay: updateEventStartDay,
         submitCreateUpdateForm: submitCreateUpdateForm,
-        getCalendarMonthData: getCalendarMonthData
+        getCalendarMonthData: getCalendarMonthData,
+        getCalendarUpcomingData: getCalendarUpcomingData
     };
 });
index a22a86d..8dd6da1 100644 (file)
@@ -26,12 +26,14 @@ define([], function() {
         eventFilterItem: "[data-action='filter-event-type']",
         eventType: {
             site: "[data-eventtype-site]",
+            category: "[data-eventtype-category]",
             course: "[data-eventtype-course]",
             group: "[data-eventtype-group]",
             user: "[data-eventtype-user]",
         },
         popoverType: {
             site: "[data-popover-eventtype-site]",
+            category: "[data-popover-eventtype-category]",
             course: "[data-popover-eventtype-course]",
             group: "[data-popover-eventtype-group]",
             user: "[data-popover-eventtype-user]",
@@ -39,5 +41,15 @@ define([], function() {
         calendarPeriods: {
             month: "[data-period='month']",
         },
+        courseSelector: 'select[name="course"]',
+        actions: {
+            create: '[data-action="new-event-button"]',
+            edit: '[data-action="edit"]',
+            remove: '[data-action="delete"]',
+        },
+        today: '.today',
+        day: '[data-region="day"]',
+        wrapper: '.calendarwrapper',
+        eventItem: '[data-type="event"]',
     };
 });
index ff9317f..3cd7aa0 100644 (file)
@@ -32,7 +32,7 @@ define([
     'core/modal_events',
     'core_calendar/repository',
     'core_calendar/events',
-    'core_calendar/modal_delete',
+    'core_calendar/crud',
 ],
 function(
     $,
@@ -45,7 +45,7 @@ function(
     ModalEvents,
     CalendarRepository,
     CalendarEvents,
-    ModalDelete
+    CalendarCrud
 ) {
 
     var registered = false;
@@ -110,6 +110,18 @@ function(
         return this.getBody().find(SELECTORS.ROOT).attr('data-event-id');
     };
 
+    /**
+     * Get the title for the event being shown in this modal. This value is
+     * not cached because it will change depending on which event is
+     * being displayed.
+     *
+     * @method getEventTitle
+     * @return {String}
+     */
+    ModalEventSummary.prototype.getEventTitle = function() {
+        return this.getBody().find(SELECTORS.ROOT).attr('data-event-title');
+    };
+
     /**
      * Get the number of events in the series for the event being shown in
      * this modal. This value is not cached because it will change
@@ -119,7 +131,7 @@ function(
      * @return {int}
      */
     ModalEventSummary.prototype.getEventCount = function() {
-        return this.getBody().find(SELECTORS.ROOT).attr('data-event-event-count');
+        return this.getBody().find(SELECTORS.ROOT).attr('data-event-count');
     };
 
     /**
@@ -154,8 +166,19 @@ function(
         // We have to wait for the modal to finish rendering in order to ensure that
         // the data-event-title property is available to use as the modal title.
         this.getRoot().on(ModalEvents.bodyRendered, function() {
-            var eventTitle = this.getBody().find(SELECTORS.ROOT).attr('data-event-title');
-            prepareDeleteAction(this, eventTitle);
+            this.getModal().data({
+                eventTitle: this.getEventTitle(),
+                eventId: this.getEventId(),
+                eventCount: this.getEventCount(),
+            })
+            .attr('data-type', 'event');
+            CalendarCrud.registerRemove(this.getModal());
+
+        }.bind(this));
+
+        $('body').on(CalendarEvents.deleted, function() {
+            // Close the dialogue on delete.
+            this.hide();
         }.bind(this));
 
         CustomEvents.define(this.getEditButton(), [
@@ -163,7 +186,6 @@ function(
         ]);
 
         this.getEditButton().on(CustomEvents.events.activate, function(e, data) {
-
             if (this.isActionEvent()) {
                 // Action events cannot be edited on the event form and must be redirected to the module UI.
                 $('body').trigger(CalendarEvents.editActionEvent, [this.getEditUrl()]);
@@ -184,90 +206,6 @@ function(
         }.bind(this));
     };
 
-    /**
-     * Prepares the action for the summary modal's delete action.
-     *
-     * @param {ModalEventSummary} summaryModal The summary modal instance.
-     * @param {string} eventTitle The event title.
-     */
-    function prepareDeleteAction(summaryModal, eventTitle) {
-        var deleteStrings = [
-            {
-                key: 'deleteevent',
-                component: 'calendar'
-            },
-        ];
-
-        var eventCount = parseInt(summaryModal.getEventCount(), 10);
-        var deletePromise;
-        var isRepeatedEvent = eventCount > 1;
-        if (isRepeatedEvent) {
-            deleteStrings.push({
-                key: 'confirmeventseriesdelete',
-                component: 'calendar',
-                param: {
-                    name: eventTitle,
-                    count: eventCount,
-                },
-            });
-
-            deletePromise = ModalFactory.create(
-                {
-                    type: ModalDelete.TYPE
-                },
-                summaryModal.getDeleteButton()
-            );
-        } else {
-            deleteStrings.push({
-                key: 'confirmeventdelete',
-                component: 'calendar',
-                param: eventTitle
-            });
-
-            deletePromise = ModalFactory.create(
-                {
-                    type: ModalFactory.types.SAVE_CANCEL
-                },
-                summaryModal.getDeleteButton()
-            );
-        }
-
-        var eventId = summaryModal.getEventId();
-        var stringsPromise = Str.get_strings(deleteStrings);
-
-        $.when(stringsPromise, deletePromise)
-        .then(function(strings, deleteModal) {
-            deleteModal.setTitle(strings[0]);
-            deleteModal.setBody(strings[1]);
-            if (!isRepeatedEvent) {
-                deleteModal.setSaveButtonText(strings[0]);
-            }
-
-            deleteModal.getRoot().on(ModalEvents.save, function() {
-                CalendarRepository.deleteEvent(eventId, false)
-                    .then(function() {
-                        $('body').trigger(CalendarEvents.deleted, [eventId, false]);
-                        summaryModal.hide();
-                        return;
-                    })
-                    .catch(Notification.exception);
-            });
-
-            deleteModal.getRoot().on(CalendarEvents.deleteAll, function() {
-                CalendarRepository.deleteEvent(eventId, true)
-                    .then(function() {
-                        $('body').trigger(CalendarEvents.deleted, [eventId, true]);
-                        summaryModal.hide();
-                        return;
-                    })
-                    .catch(Notification.exception);
-            });
-
-            return deleteModal;
-        })
-        .fail(Notification.exception);
-    }
-
     // Automatically register with the modal registry the first time this module is imported so that you can create modals
     // of this type using the modal factory.
     if (!registered) {
index 0934a51..bf62b76 100644 (file)
@@ -40,9 +40,11 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
             root = $(root);
 
             root.on('click', SELECTORS.CALENDAR_NAV_LINK, function(e) {
-                var courseId = $(root).find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid');
+                var wrapper = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER);
+                var courseId = wrapper.data('courseid');
+                var categoryId = wrapper.data('categoryid');
                 var link = $(e.currentTarget);
-                changeMonth(root, link.attr('href'), link.data('year'), link.data('month'), courseId);
+                changeMonth(root, link.attr('href'), link.data('year'), link.data('month'), courseId, categoryId);
 
                 e.preventDefault();
             });
@@ -55,17 +57,18 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
          * @param {Number} year Year
          * @param {Number} month Month
          * @param {Number} courseid The id of the course whose events are shown
+         * @param {Number} categoryid The id of the category whose events are shown
          * @param {object} target The element being replaced. If not specified, the calendarwrapper is used.
          * @return {promise}
          */
-        var refreshMonthContent = function(root, year, month, courseid, target) {
+        var refreshMonthContent = function(root, year, month, courseid, categoryid, target) {
             startLoading(root);
 
             target = target || root.find(SELECTORS.CALENDAR_MONTH_WRAPPER);
 
             M.util.js_pending([root.get('id'), year, month, courseid].join('-'));
             var includenavigation = root.data('includenavigation');
-            return CalendarRepository.getCalendarMonthData(year, month, courseid, includenavigation)
+            return CalendarRepository.getCalendarMonthData(year, month, courseid, categoryid, includenavigation)
                 .then(function(context) {
                     return Templates.render(root.attr('data-template'), context);
                 })
@@ -86,15 +89,16 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
         /**
          * Handle changes to the current calendar view.
          *
-         * @param {object} root The root element.
+         * @param {object} root The container element
          * @param {String} url The calendar url to be shown
          * @param {Number} year Year
          * @param {Number} month Month
          * @param {Number} courseid The id of the course whose events are shown
+         * @param {Number} categoryid The id of the category whose events are shown
          * @return {promise}
          */
-        var changeMonth = function(root, url, year, month, courseid) {
-            return refreshMonthContent(root, year, month, courseid)
+        var changeMonth = function(root, url, year, month, courseid, categoryid) {
+            return refreshMonthContent(root, year, month, courseid, categoryid)
                 .then(function() {
                     if (url.length && url !== '#') {
                         window.history.pushState({}, '', url);
@@ -102,7 +106,7 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
                     return arguments;
                 })
                 .then(function() {
-                    $('body').trigger(CalendarEvents.monthChanged, [year, month, courseid]);
+                    $('body').trigger(CalendarEvents.monthChanged, [year, month, courseid, categoryid]);
                     return arguments;
                 });
         };
@@ -112,16 +116,23 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
          *
          * @param {object} root The container element.
          * @param {Number} courseId The course id.
+         * @param {Number} categoryId The id of the category whose events are shown
          * @return {promise}
          */
-        var reloadCurrentMonth = function(root, courseId) {
+        var reloadCurrentMonth = function(root, courseId, categoryId) {
             var year = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('year');
             var month = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('month');
 
-            if (!courseId) {
+            if (typeof courseId === 'undefined') {
                 courseId = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid');
             }
-            return refreshMonthContent(root, year, month, courseId);
+
+            if (typeof categoryId === 'undefined') {
+                categoryId = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('categoryid');
+            }
+
+            return refreshMonthContent(root, year, month, courseId, categoryId);
+
         };
 
         /**
@@ -148,12 +159,47 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
             loadingIconContainer.addClass('hidden');
         };
 
+        /**
+         * Reload the current month view data.
+         *
+         * @param {object} root The container element.
+         * @param {Number} courseId The course id.
+         * @return {promise}
+         */
+        var reloadCurrentUpcoming = function(root, courseId) {
+            startLoading(root);
+
+            var target = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER);
+
+            if (!courseId) {
+                courseId = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid');
+            }
+
+            return CalendarRepository.getCalendarUpcomingData(courseId)
+                .then(function(context) {
+                    return Templates.render(root.attr('data-template'), context);
+                })
+                .then(function(html, js) {
+                    window.history.replaceState(null, null, '?view=upcoming&course=' + courseId);
+                    return Templates.replaceNode(target, html, js);
+                })
+                .then(function() {
+                    $('body').trigger(CalendarEvents.viewUpdated);
+                    return;
+                })
+                .always(function() {
+                    return stopLoading(root);
+                })
+                .fail(Notification.exception);
+        };
+
         return {
             init: function(root) {
                 registerEventListeners(root);
             },
             reloadCurrentMonth: reloadCurrentMonth,
             changeMonth: changeMonth,
-            refreshMonthContent: refreshMonthContent
+            refreshMonthContent: refreshMonthContent,
+            reloadCurrentUpcoming: reloadCurrentUpcoming
         };
     });
index f20c7f7..423cd92 100644 (file)
@@ -82,6 +82,8 @@ class calendar_event_exporter extends event_exporter_base {
             $params = array('update' => $moduleid, 'return' => true, 'sesskey' => sesskey());
             $editurl = new \moodle_url('/course/mod.php', $params);
             $values['editurl'] = $editurl->out(false);
+        } else if ($event->get_type() == 'category') {
+            $url = $event->get_category()->get_proxied_instance()->get_view_link();
         } else if ($event->get_type() == 'course') {
             $url = course_get_url($event->get_course()->get('id') ?: SITEID);
         } else {
@@ -126,6 +128,17 @@ class calendar_event_exporter extends event_exporter_base {
             }
         }
 
+        // Include category name into the event name, if applicable.
+        $proxy = $this->event->get_category();
+        if ($proxy && $proxy->get('id')) {
+            $category = $proxy->get_proxied_instance();
+            $eventnameparams = (object) [
+                'name' => $values['popupname'],
+                'category' => $category->get_formatted_name(),
+            ];
+            $values['popupname'] = get_string('eventnameandcategory', 'calendar', $eventnameparams);
+        }
+
         // Include course's shortname into the event name, if applicable.
         $course = $this->event->get_course();
         if ($course && $course->get('id') && $course->get('id') !== SITEID) {
diff --git a/calendar/classes/external/calendar_upcoming_exporter.php b/calendar/classes/external/calendar_upcoming_exporter.php
new file mode 100644 (file)
index 0000000..762de98
--- /dev/null
@@ -0,0 +1,169 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains event class for displaying the upcoming view.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_calendar\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\external\exporter;
+use renderer_base;
+use moodle_url;
+use \core_calendar\local\event\container;
+
+/**
+ * Class for displaying the day view.
+ *
+ * @package   core_calendar
+ * @copyright 2017 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class calendar_upcoming_exporter extends exporter {
+    /**
+     * @var \calendar_information $calendar The calendar to be rendered.
+     */
+    protected $calendar;
+
+    /**
+     * @var moodle_url $url The URL for the upcoming view page.
+     */
+    protected $url;
+
+    /**
+     * Constructor for upcoming exporter.
+     *
+     * @param \calendar_information $calendar The calendar being represented.
+     * @param array $related The related information
+     */
+    public function __construct(\calendar_information $calendar, $related) {
+        $this->calendar = $calendar;
+
+        parent::__construct([], $related);
+    }
+
+    /**
+     * Return the list of additional properties.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'events' => [
+                'type' => calendar_event_exporter::read_properties_definition(),
+                'multiple' => true,
+            ],
+            'defaulteventcontext' => [
+                'type' => PARAM_INT,
+                'default' => null,
+            ],
+            'filter_selector' => [
+                'type' => PARAM_RAW,
+            ],
+            'courseid' => [
+                'type' => PARAM_INT,
+            ],
+        ];
+    }
+
+    /**
+     * Get the additional values to inject while exporting.
+     *
+     * @param renderer_base $output The renderer.
+     * @return array Keys are the property names, values are their values.
+     */
+    protected function get_other_values(renderer_base $output) {
+        $timestamp = $this->calendar->time;
+
+        $cache = $this->related['cache'];
+        $url = new moodle_url('/calendar/view.php', [
+            'view' => 'upcoming',
+            'time' => $timestamp,
+            'course' => $this->calendar->course->id,
+        ]);
+        $this->url = $url;
+        $return['events'] = array_map(function($event) use ($cache, $output, $url) {
+            $context = $cache->get_context($event);
+            $course = $cache->get_course($event);
+            $exporter = new calendar_event_exporter($event, [
+                'context' => $context,
+                'course' => $course,
+                'daylink' => $url,
+                'type' => $this->related['type'],
+                'today' => $this->calendar->time,
+            ]);
+
+            $data = $exporter->export($output);
+
+            // We need to override default formatted time because it differs from day view.
+            // Formatted time for upcoming view adds a link to the day view.
+            $legacyevent = container::get_event_mapper()->from_event_to_legacy_event($event);
+            $data->formattedtime = calendar_format_event_time($legacyevent, time(), null);
+
+            return $data;
+        }, $this->related['events']);
+
+        if ($context = $this->get_default_add_context()) {
+            $return['defaulteventcontext'] = $context->id;
+        }
+        $return['filter_selector'] = $this->get_course_filter_selector($output);
+        $return['courseid'] = $this->calendar->courseid;
+        return $return;
+    }
+
+    /**
+     * Get the default context for use when adding a new event.
+     *
+     * @return null|\context
+     */
+    protected function get_default_add_context() {
+        if (calendar_user_can_add_event($this->calendar->course)) {
+            return \context_course::instance($this->calendar->course->id);
+        }
+
+        return null;
+    }
+
+    /**
+     * Get the course filter selector.
+     *
+     * @param renderer_base $output
+     * @return string The html code for the course filter selector.
+     */
+    protected function get_course_filter_selector(renderer_base $output) {
+        $langstr = get_string('upcomingeventsfor', 'calendar');
+        return $output->course_filter_selector($this->url, $langstr);
+    }
+
+    /**
+     * Returns a list of objects that are related.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'events' => '\core_calendar\local\event\entities\event_interface[]',
+            'cache' => '\core_calendar\external\events_related_objects_cache',
+            'type' => '\core_calendar\type_base',
+        ];
+    }
+}
index 2965451..ebe8d86 100644 (file)
@@ -59,11 +59,20 @@ class day_exporter extends exporter {
      */
     public function __construct(\calendar_information $calendar, $data, $related) {
         $this->calendar = $calendar;
-        $this->url = new moodle_url('/calendar/view.php', [
-            'view' => 'day',
-            'time' => $calendar->time,
-            'course' => $this->calendar->course->id,
-        ]);
+
+        $url = new moodle_url('/calendar/view.php', [
+                'view' => 'day',
+                'time' => $calendar->time,
+            ]);
+
+        if ($this->calendar->course && SITEID !== $this->calendar->course->id) {
+            $url->param('course', $this->calendar->course->id);
+        } else if ($this->calendar->categoryid) {
+            $url->param('category', $this->calendar->categoryid);
+        }
+
+        $this->url = $url;
+
         parent::__construct($data, $related);
     }
 
@@ -179,9 +188,9 @@ class day_exporter extends exporter {
             'navigation' => $this->get_navigation(),
             'filter_selector' => $this->get_course_filter_selector($output),
             'new_event_button' => $this->get_new_event_button(),
+            'viewdaylink' => $this->url->out(false),
         ];
 
-        $return['viewdaylink'] = $this->url->out(false);
 
         $cache = $this->related['cache'];
         $eventexporters = array_map(function($event) use ($cache, $output) {
index 2642423..5ebfdce 100644 (file)
@@ -48,17 +48,13 @@ class event_exporter extends event_exporter_base {
      * @return array
      */
     protected static function define_other_properties() {
-
         $values = parent::define_other_properties();
+
         $values['url'] = ['type' => PARAM_URL];
         $values['action'] = [
             'type' => event_action_exporter::read_properties_definition(),
             'optional' => true,
         ];
-        $values['editurl'] = [
-            'type' => PARAM_URL,
-            'optional' => true,
-        ];
 
         return $values;
     }
@@ -86,6 +82,8 @@ class event_exporter extends event_exporter_base {
             $params = array('update' => $moduleid, 'return' => true, 'sesskey' => sesskey());
             $editurl = new \moodle_url('/course/mod.php', $params);
             $values['editurl'] = $editurl->out(false);
+        } else if ($event->get_type() == 'category') {
+            $url = $event->get_category()->get_proxied_instance()->get_view_link();
         } else if ($event->get_type() == 'course') {
             $url = \course_get_url($this->related['course'] ?: SITEID);
         } else {
index cb3ba80..85457e2 100644 (file)
@@ -34,6 +34,7 @@ use \core_calendar\local\event\container;
 use \core_calendar\local\event\entities\event_interface;
 use \core_calendar\local\event\entities\action_event_interface;
 use \core_course\external\course_summary_exporter;
+use \core\external\coursecat_summary_exporter;
 use \renderer_base;
 use moodle_url;
 
@@ -64,6 +65,7 @@ class event_exporter_base extends exporter {
         $endtimestamp = $event->get_times()->get_end_time()->getTimestamp();
         $groupid = $event->get_group() ? $event->get_group()->get('id') : null;
         $userid = $event->get_user() ? $event->get_user()->get('id') : null;
+        $categoryid = $event->get_category() ? $event->get_category()->get('id') : null;
 
         $data = new \stdClass();
         $data->id = $event->get_id();
@@ -79,6 +81,7 @@ class event_exporter_base extends exporter {
         $data->descriptionformat = $event->get_description()->get_format();
         $data->groupid = $groupid;
         $data->userid = $userid;
+        $data->categoryid = $categoryid;
         $data->eventtype = $event->get_type();
         $data->timestart = $starttimestamp;
         $data->timeduration = $endtimestamp - $starttimestamp;
@@ -120,6 +123,12 @@ class event_exporter_base extends exporter {
                 'default' => null,
                 'null' => NULL_ALLOWED
             ],
+            'categoryid' => [
+                'type' => PARAM_INT,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
             'groupid' => [
                 'type' => PARAM_INT,
                 'optional' => true,
@@ -175,6 +184,10 @@ class event_exporter_base extends exporter {
             'icon' => [
                 'type' => event_icon_exporter::read_properties_definition(),
             ],
+            'category' => [
+                'type' => coursecat_summary_exporter::read_properties_definition(),
+                'optional' => true,
+            ],
             'course' => [
                 'type' => course_summary_exporter::read_properties_definition(),
                 'optional' => true,
@@ -204,6 +217,9 @@ class event_exporter_base extends exporter {
             'iscourseevent' => [
                 'type' => PARAM_BOOL
             ],
+            'iscategoryevent' => [
+                'type' => PARAM_BOOL
+            ],
             'groupname' => [
                 'type' => PARAM_RAW,
                 'optional' => true,
@@ -226,10 +242,13 @@ class event_exporter_base extends exporter {
         $context = $this->related['context'];
         $values['isactionevent'] = false;
         $values['iscourseevent'] = false;
+        $values['iscategoryevent'] = false;
         if ($moduleproxy = $event->get_course_module()) {
             $values['isactionevent'] = true;
         } else if ($event->get_type() == 'course') {
             $values['iscourseevent'] = true;
+        } else if ($event->get_type() == 'category') {
+            $values['iscategoryevent'] = true;
         }
         $timesort = $event->get_times()->get_sort_time()->getTimestamp();
         $iconexporter = new event_icon_exporter($event, ['context' => $context]);
@@ -239,6 +258,13 @@ class event_exporter_base extends exporter {
         $subscriptionexporter = new event_subscription_exporter($event);
         $values['subscription'] = $subscriptionexporter->export($output);
 
+        $proxy = $this->event->get_category();
+        if ($proxy && $proxy->get('id')) {
+            $category = $proxy->get_proxied_instance();
+            $categorysummaryexporter = new coursecat_summary_exporter($category, ['context' => $context]);
+            $values['category'] = $categorysummaryexporter->export($output);
+        }
+
         if ($course = $this->related['course']) {
             $coursesummaryexporter = new course_summary_exporter($course, ['context' => $context]);
             $values['course'] = $coursesummaryexporter->export($output);
index 5f4bd4d..79d30da 100644 (file)
@@ -46,6 +46,8 @@ class event_icon_exporter extends exporter {
      */
     public function __construct(event_interface $event, $related = []) {
         $coursemodule = $event->get_course_module();
+        $category = $event->get_category();
+        $categoryid = $category ? $category->get('id') : null;
         $course = $event->get_course();
         $courseid = $course ? $course->get('id') : null;
         $group = $event->get_group();
@@ -54,6 +56,7 @@ class event_icon_exporter extends exporter {
         $userid = $user ? $user->get('id') : null;
         $isactivityevent = !empty($coursemodule);
         $isglobalevent = ($course && $courseid == SITEID);
+        $iscategoryevent = ($category && !empty($categoryid));
         $iscourseevent = ($course && !empty($courseid) && $courseid != SITEID && empty($groupid));
         $isgroupevent = ($group && !empty($groupid));
         $isuserevent = ($user && !empty($userid));
@@ -70,24 +73,28 @@ class event_icon_exporter extends exporter {
         } else if ($isglobalevent) {
             $key = 'i/siteevent';
             $component = 'core';
-            $alttext = get_string('globalevent', 'calendar');
+            $alttext = get_string('typesite', 'calendar');
+        } else if ($iscategoryevent) {
+            $key = 'i/categoryevent';
+            $component = 'core';
+            $alttext = get_string('typecategory', 'calendar');
         } else if ($iscourseevent) {
             $key = 'i/courseevent';
             $component = 'core';
-            $alttext = get_string('courseevent', 'calendar');
+            $alttext = get_string('typecourse', 'calendar');
         } else if ($isgroupevent) {
             $key = 'i/groupevent';
             $component = 'core';
-            $alttext = get_string('groupevent', 'calendar');
+            $alttext = get_string('typegroup', 'calendar');
         } else if ($isuserevent) {
             $key = 'i/userevent';
             $component = 'core';
-            $alttext = get_string('userevent', 'calendar');
+            $alttext = get_string('typeuser', 'calendar');
         } else {
             // Default to site event icon?
             $key = 'i/siteevent';
             $component = 'core';
-            $alttext = get_string('globalevent', 'calendar');
+            $alttext = get_string('typesite', 'calendar');
         }
 
         $data = new \stdClass();
index b71ccc8..e67c843 100644 (file)
@@ -75,8 +75,10 @@ class month_exporter extends exporter {
                 'time' => $calendar->time,
             ]);
 
-        if ($this->calendar->courseid) {
-            $this->url->param('course', $this->calendar->courseid);
+        if ($this->calendar->course && SITEID !== $this->calendar->course->id) {
+            $this->url->param('course', $this->calendar->course->id);
+        } else if ($this->calendar->categoryid) {
+            $this->url->param('category', $this->calendar->categoryid);
         }
 
         $related['type'] = $type;
@@ -106,6 +108,11 @@ class month_exporter extends exporter {
             'courseid' => [
                 'type' => PARAM_INT,
             ],
+            'categoryid' => [
+                'type' => PARAM_INT,
+                'optional' => true,
+                'default' => 0,
+            ],
             'filter_selector' => [
                 'type' => PARAM_RAW,
             ],
@@ -209,6 +216,10 @@ class month_exporter extends exporter {
             $return['defaulteventcontext'] = $context->id;
         }
 
+        if ($this->calendar->categoryid) {
+            $return['categoryid'] = $this->calendar->categoryid;
+        }
+
         return $return;
     }
 
index e974154..5d87d18 100644 (file)
@@ -118,10 +118,16 @@ class week_day_exporter extends day_exporter {
         $url = new moodle_url('/calendar/view.php', [
                 'view' => 'day',
                 'time' => $timestamp,
-                'course' => $this->calendar->course->id,
-        ]);
+            ]);
+
+        if ($this->calendar->course && SITEID !== $this->calendar->course->id) {
+            $url->param('course', $this->calendar->course->id);
+        } else if ($this->calendar->categoryid) {
+            $url->param('category', $this->calendar->categoryid);
+        }
 
         $return['viewdaylink'] = $url->out(false);
+
         if ($popovertitle = $this->get_popover_title()) {
             $return['popovertitle'] = $popovertitle;
         }
index 0ba6d54..00713f4 100644 (file)
@@ -71,6 +71,7 @@ class api {
         array $usersfilter = null,
         array $groupsfilter = null,
         array $coursesfilter = null,
+        array $categoriesfilter = null,
         $withduration = true,
         $ignorehidden = true,
         callable $filter = null
@@ -102,6 +103,7 @@ class api {
             $usersfilter,
             $groupsfilter,
             $coursesfilter,
+            $categoriesfilter,
             $withduration,
             $ignorehidden,
             $filter
index 70c2cb7..398304e 100644 (file)
@@ -117,6 +117,15 @@ class container {
                 [self::class, 'apply_component_provide_event_action'],
                 [self::class, 'apply_component_is_event_visible'],
                 function ($dbrow) {
+                    if (!empty($dbrow->categoryid)) {
+                        // This is a category event. Check that the category is visible to this user.
+                        $category = \coursecat::get($dbrow->categoryid, IGNORE_MISSING, true);
+
+                        if (empty($category) || !$category->is_uservisible()) {
+                            return true;
+                        }
+                    }
+
                     // At present we only have a bail-out check for events in course modules.
                     if (empty($dbrow->modulename)) {
                         return false;
index c6c7c68..9111f39 100644 (file)
@@ -33,6 +33,8 @@ use core_calendar\local\event\factories\action_factory_interface;
 use core_calendar\local\event\factories\event_factory_interface;
 use core_calendar\local\event\strategies\raw_event_retrieval_strategy_interface;
 
+require_once($CFG->libdir . '/coursecatlib.php');
+
 /**
  * Event vault class.
  *
@@ -95,6 +97,7 @@ class event_vault implements event_vault_interface {
         array $usersfilter = null,
         array $groupsfilter = null,
         array $coursesfilter = null,
+        array $categoriesfilter = null,
         $withduration = true,
         $ignorehidden = true,
         callable $filter = null
@@ -162,6 +165,7 @@ class event_vault implements event_vault_interface {
             $usersfilter,
             $groupsfilter,
             $coursesfilter,
+            $categoriesfilter,
             $where,
             $params,
             "COALESCE(e.timesort, e.timestart) ASC, e.id ASC",
@@ -197,6 +201,10 @@ class event_vault implements event_vault_interface {
         event_interface $afterevent = null,
         $limitnum = 20
     ) {
+        $categoryids = array_map(function($category) {
+            return $category->id;
+        }, \coursecat::get_all());
+
         $courseids = array_map(function($course) {
             return $course->id;
         }, enrol_get_all_users_courses($user->id));
@@ -219,6 +227,7 @@ class event_vault implements event_vault_interface {
             [$user->id],
             $groupids ? $groupids : null,
             $courseids ? $courseids : null,
+            $categoryids ? $categoryids : null,
             true,
             true,
             function ($event) {
@@ -249,6 +258,7 @@ class event_vault implements event_vault_interface {
                 [$user->id],
                 $groupings[0] ? $groupings[0] : null,
                 [$course->id],
+                [],
                 true,
                 true,
                 function ($event) use ($course) {
@@ -375,6 +385,7 @@ class event_vault implements event_vault_interface {
                 [$userid],
                 null,
                 null,
+                null,
                 $whereconditions,
                 $whereparams,
                 $ordersql,
index 705b832..15c5a0c 100644 (file)
@@ -76,6 +76,7 @@ interface event_vault_interface {
         array $usersfilter = null,
         array $groupsfilter = null,
         array $coursesfilter = null,
+        array $categoriesfilter = null,
         $withduration = true,
         $ignorehidden = true,
         callable $filter = null
index 1c76999..ad1d98c 100644 (file)
@@ -50,6 +50,11 @@ class action_event implements action_event_interface {
      */
     protected $action;
 
+    /**
+     * @var proxy_interface $category Category for this event.
+     */
+    protected $category;
+
     /**
      * Constructor.
      *
@@ -73,6 +78,10 @@ class action_event implements action_event_interface {
         return $this->event->get_description();
     }
 
+    public function get_category() {
+        return $this->event->get_category();
+    }
+
     public function get_course() {
         return $this->event->get_course();
     }
index cd5a1df..f68544b 100644 (file)
@@ -52,6 +52,11 @@ class event implements event_interface {
      */
     protected $description;
 
+    /**
+     * @var proxy_interface $category Category for this event.
+     */
+    protected $category;
+
     /**
      * @var proxy_interface $course Course for this event.
      */
@@ -103,6 +108,7 @@ class event implements event_interface {
      * @param int                        $id             The event's ID in the database.
      * @param string                     $name           The event's name.
      * @param description_interface      $description    The event's description.
+     * @param proxy_interface            $category       The category associated with the event.
      * @param proxy_interface            $course         The course associated with the event.
      * @param proxy_interface            $group          The group associated with the event.
      * @param proxy_interface            $user           The user associated with the event.
@@ -117,6 +123,7 @@ class event implements event_interface {
         $id,
         $name,
         description_interface $description,
+        proxy_interface $category = null,
         proxy_interface $course = null,
         proxy_interface $group = null,
         proxy_interface $user = null,
@@ -130,6 +137,7 @@ class event implements event_interface {
         $this->id = $id;
         $this->name = $name;
         $this->description = $description;
+        $this->category = $category;
         $this->course = $course;
         $this->group = $group;
         $this->user = $user;
@@ -153,6 +161,10 @@ class event implements event_interface {
         return $this->description;
     }
 
+    public function get_category() {
+        return $this->category;
+    }
+
     public function get_course() {
         return $this->course;
     }
index 192e75d..400cf78 100644 (file)
@@ -56,6 +56,13 @@ interface event_interface {
      */
     public function get_description();
 
+    /**
+     * Get the category object associated with the event.
+     *
+     * @return proxy_interface
+     */
+    public function get_category();
+
     /**
      * Get the course object associated with the event.
      *
index 2695d93..b0242ae 100644 (file)
@@ -30,11 +30,14 @@ use core_calendar\local\event\entities\event;
 use core_calendar\local\event\entities\repeat_event_collection;
 use core_calendar\local\event\exceptions\invalid_callback_exception;
 use core_calendar\local\event\proxies\cm_info_proxy;
+use core_calendar\local\event\proxies\coursecat_proxy;
 use core_calendar\local\event\proxies\std_proxy;
 use core_calendar\local\event\value_objects\event_description;
 use core_calendar\local\event\value_objects\event_times;
 use core_calendar\local\event\entities\event_interface;
 
+require_once($CFG->libdir . '/coursecatlib.php');
+
 /**
  * Abstract factory for creating calendar events.
  *
@@ -126,6 +129,7 @@ abstract class event_abstract_factory implements event_factory_interface {
             return null;
         }
 
+        $category = null;
         $course = null;
         $group = null;
         $user = null;
@@ -136,6 +140,8 @@ abstract class event_abstract_factory implements event_factory_interface {
             $module = new cm_info_proxy($dbrow->modulename, $dbrow->instance, $dbrow->courseid);
         }
 
+        $category = new coursecat_proxy($dbrow->categoryid);
+
         $course = new std_proxy($dbrow->courseid, function($id) {
             return calendar_get_course_cached($this->coursecachereference, $id);
         });
@@ -163,6 +169,7 @@ abstract class event_abstract_factory implements event_factory_interface {
             $dbrow->id,
             $dbrow->name,
             new event_description($dbrow->description, $dbrow->format),
+            $category,
             $course,
             $group,
             $user,
index 0a6b757..8892bfb 100644 (file)
@@ -55,7 +55,7 @@ class create extends \moodleform {
     /**
      * The form definition
      */
-    public function definition () {
+    public function definition() {
         global $PAGE;
 
         $mform = $this->_form;
@@ -203,6 +203,9 @@ class create extends \moodleform {
         if (isset($eventtypes['course'])) {
             $options['course'] = get_string('course');
         }
+        if (isset($eventtypes['category'])) {
+            $options['category'] = get_string('category');
+        }
         if (isset($eventtypes['site'])) {
             $options['site'] = get_string('site');
         }
@@ -223,6 +226,16 @@ class create extends \moodleform {
             $mform->addElement('select', 'eventtype', get_string('eventkind', 'calendar'), $options);
         }
 
+        if (isset($eventtypes['category'])) {
+            $categoryoptions = [];
+            foreach ($eventtypes['category'] as $id => $category) {
+                $categoryoptions[$id] = $category;
+            }
+
+            $mform->addElement('select', 'categoryid', get_string('category'), $categoryoptions);
+            $mform->hideIf('categoryid', 'eventtype', 'noteq', 'category');
+        }
+
         if (isset($eventtypes['course'])) {
             $courseoptions = [];
             foreach ($eventtypes['course'] as $course) {
index 1561e02..2e33869 100644 (file)
@@ -69,6 +69,7 @@ class event_mapper implements event_mapper_interface {
                 'name' => $coalesce('name'),
                 'description' => $coalesce('description'),
                 'format' => $coalesce('format'),
+                'categoryid' => $coalesce('categoryid'),
                 'courseid' => $coalesce('courseid'),
                 'groupid' => $coalesce('groupid'),
                 'userid' => $coalesce('userid'),
diff --git a/calendar/classes/local/event/proxies/coursecat_proxy.php b/calendar/classes/local/event/proxies/coursecat_proxy.php
new file mode 100644 (file)
index 0000000..c585f3a
--- /dev/null
@@ -0,0 +1,92 @@
+<?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/>.
+
+/**
+ * Course category proxy.
+ *
+ * @package    core_calendar
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_calendar\local\event\proxies;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/coursecatlib.php');
+
+/**
+ * Course category proxy.
+ *
+ * This returns an instance of a coursecat rather than a stdClass.
+ *
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class coursecat_proxy implements proxy_interface {
+    /**
+     * @var int $id The ID of the database record.
+     */
+    protected $id;
+
+    /**
+     * @var \stdClass $base Base class to get members from.
+     */
+    protected $base;
+
+    /**
+     * @var \coursecat $category The proxied instance.
+     */
+    protected $category;
+
+    /**
+     * coursecat_proxy constructor.
+     *
+     * @param int       $id       The ID of the record in the database.
+     */
+    public function __construct($id) {
+        $this->id = $id;
+        $this->base = (object) [
+            'id' => $id,
+        ];
+    }
+
+    /**
+     * Retrieve a member of the proxied class.
+     *
+     * @param string $member The name of the member to retrieve
+     * @return mixed The member.
+     */
+    public function get($member) {
+        if ($this->base && property_exists($this->base, $member)) {
+            return $this->base->{$member};
+        }
+
+        return $this->get_proxied_instance()->{$member};
+    }
+
+    /**
+     * Get the full instance of the proxied class.
+     *
+     * @return \coursecat
+     */
+    public function get_proxied_instance() : \coursecat {
+        if (!$this->category) {
+            $this->category = \coursecat::get($this->id, IGNORE_MISSING, true);
+        }
+        return $this->category;
+    }
+}
index 252d374..d4775ee 100644 (file)
@@ -40,6 +40,7 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
         array $usersfilter = null,
         array $groupsfilter = null,
         array $coursesfilter = null,
+        array $categoriesfilter = null,
         array $whereconditions = null,
         array $whereparams = null,
         $ordersql = null,
@@ -51,6 +52,7 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
             !is_null($usersfilter) ? $usersfilter : true, // True means no filter in old implementation.
             !is_null($groupsfilter) ? $groupsfilter : true,
             !is_null($coursesfilter) ? $coursesfilter : true,
+            !is_null($categoriesfilter) ? $categoriesfilter : true,
             $whereconditions,
             $whereparams,
             $ordersql,
@@ -78,6 +80,7 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
         $users,
         $groups,
         $courses,
+        $categories,
         $whereconditions,
         $whereparams,
         $ordersql,
@@ -89,7 +92,7 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
 
         $params = array();
         // Quick test.
-        if (empty($users) && empty($groups) && empty($courses)) {
+        if (empty($users) && empty($groups) && empty($courses) && empty($categories)) {
             return array();
         }
 
@@ -100,11 +103,11 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
         if ((is_array($users) && !empty($users)) or is_numeric($users)) {
             // Events from a number of users.
             list($insqlusers, $inparamsusers) = $DB->get_in_or_equal($users, SQL_PARAMS_NAMED);
-            $filters[] = "(e.userid $insqlusers AND e.courseid = 0 AND e.groupid = 0)";
+            $filters[] = "(e.userid $insqlusers AND e.courseid = 0 AND e.groupid = 0 AND e.categoryid = 0)";
             $params = array_merge($params, $inparamsusers);
         } else if ($users === true) {
             // Events from ALL users.
-            $filters[] = "(e.userid != 0 AND e.courseid = 0 AND e.groupid = 0)";
+            $filters[] = "(e.userid != 0 AND e.courseid = 0 AND e.groupid = 0 AND e.categoryid = 0)";
         }
         // Boolean false (no users at all): We don't need to do anything.
 
@@ -130,6 +133,16 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
             $filters[] = "(e.groupid = 0 AND e.courseid != 0)";
         }
 
+        // Category filter.
+        if ((is_array($categories) && !empty($categories)) or is_numeric($categories)) {
+            list($insqlcategories, $inparamscategories) = $DB->get_in_or_equal($categories, SQL_PARAMS_NAMED);
+            $filters[] = "(e.groupid = 0 AND e.courseid = 0 AND e.categoryid $insqlcategories)";
+            $params = array_merge($params, $inparamscategories);
+        } else if ($categories === true) {
+            // Events from ALL categories.
+            $filters[] = "(e.groupid = 0 AND e.courseid = 0 AND e.categoryid != 0)";
+        }
+
         // Security check: if, by now, we have NOTHING in $whereclause, then it means
         // that NO event-selecting clauses were defined. Thus, we won't be returning ANY
         // events no matter what. Allowing the code to proceed might return a completely
@@ -168,7 +181,7 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
 
         if ($user) {
             // Set filter condition for the user's events.
-            $subqueryconditions[] = "(ev.userid = :user AND ev.courseid = 0 AND ev.groupid = 0)";
+            $subqueryconditions[] = "(ev.userid = :user AND ev.courseid = 0 AND ev.groupid = 0 AND ev.categoryid = 0)";
             $subqueryparams['user'] = $user;
 
             foreach ($usercourses as $courseid) {
@@ -210,10 +223,19 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
         // Set subquery filter condition for the courses.
         if (!empty($subquerycourses)) {
             list($incourses, $incoursesparams) = $DB->get_in_or_equal($subquerycourses, SQL_PARAMS_NAMED);
-            $subqueryconditions[] = "(ev.groupid = 0 AND ev.courseid $incourses)";
+            $subqueryconditions[] = "(ev.groupid = 0 AND ev.courseid $incourses AND ev.categoryid = 0)";
             $subqueryparams = array_merge($subqueryparams, $incoursesparams);
         }
 
+        // Set subquery filter condition for the categories.
+        if ($categories === true) {
+            $subqueryconditions[] = "(ev.categoryid != 0 AND ev.eventtype = 'category')";
+        } else if (!empty($categories)) {
+            list($incategories, $incategoriesparams) = $DB->get_in_or_equal($categories, SQL_PARAMS_NAMED);
+            $subqueryconditions[] = "(ev.groupid = 0 AND ev.courseid = 0 AND ev.categoryid $incategories)";
+            $subqueryparams = array_merge($subqueryparams, $incategoriesparams);
+        }
+
         // Build the WHERE condition for the sub-query.
         if (!empty($subqueryconditions)) {
             $subquerywhere = 'WHERE ' . implode(" OR ", $subqueryconditions);
index cc670d3..9225a39 100644 (file)
@@ -39,6 +39,7 @@ interface raw_event_retrieval_strategy_interface {
      * @param array|null    $usersfilter     Array of users to retrieve events for.
      * @param array|null    $groupsfilter    Array of groups to retrieve events for.
      * @param array|null    $coursesfilter   Array of courses to retrieve events for.
+     * @param array|null    $categoriesfilter Array of categories to retrieve events for.
      * @param array|null    $whereconditions Array of where conditions to restrict results.
      * @param array|null    $whereparams     Array of parameters for $whereconditions.
      * @param string|null   $ordersql        SQL to order results.
@@ -51,6 +52,7 @@ interface raw_event_retrieval_strategy_interface {
         array $usersfilter = null,
         array $groupsfilter = null,
         array $coursesfilter = null,
+        array $categoriesfilter = null,
         array $whereconditions = null,
         array $whereparams = null,
         $ordersql = null,
index dad5fd5..9eb3708 100644 (file)
@@ -108,7 +108,7 @@ if ($action === 'delete' && $eventid > 0) {
 }
 
 $calendar = new calendar_information(0, 0, 0, $time);
-$calendar->prepare_for_view($course, $courses);
+$calendar->set_sources($course, $courses);
 
 $formoptions = new stdClass;
 if ($eventid !== 0) {
index 04e6cf3..89ca1e0 100644 (file)
@@ -100,7 +100,7 @@ if ($course !== NULL) {
 $PAGE->set_url($url);
 
 $calendar = new calendar_information(0, 0, 0, $time);
-$calendar->prepare_for_view($course, $courses);
+$calendar->set_sources($course, $courses);
 
 $pagetitle = get_string('export', 'calendar');
 
index 336c0f0..3436c15 100644 (file)
@@ -277,7 +277,7 @@ class core_calendar_external extends external_api {
                             (!empty($eventobj->groupid) && in_array($eventobj->groupid, $groups)) ||
                             (!empty($eventobj->courseid) && in_array($eventobj->courseid, $courses)) ||
                             ($USER->id == $eventobj->userid) ||
-                            (calendar_edit_event_allowed($eventid))) {
+                            (calendar_edit_event_allowed($eventobj))) {
                     $events[$eventid] = $event;
                 } else {
                     $warnings[] = array('item' => $eventid, 'warningcode' => 'nopermissions', 'message' => 'you do not have permissions to view this event');
@@ -882,10 +882,11 @@ class core_calendar_external extends external_api {
      * @param   int     $year The year to be shown
      * @param   int     $month The month to be shown
      * @param   int     $courseid The course to be included
+     * @param   int     $categoryid The category to be included
      * @param   bool    $includenavigation Whether to include navigation
      * @return  array
      */
-    public static function get_calendar_monthly_view($year, $month, $courseid, $includenavigation) {
+    public static function get_calendar_monthly_view($year, $month, $courseid, $categoryid, $includenavigation) {
         global $CFG, $DB, $USER, $PAGE;
         require_once($CFG->dirroot."/calendar/lib.php");
 
@@ -894,27 +895,45 @@ class core_calendar_external extends external_api {
             'year' => $year,
             'month' => $month,
             'courseid' => $courseid,
+            'categoryid' => $categoryid,
             'includenavigation' => $includenavigation,
         ]);
 
+        // TODO: Copy what we do in calendar/view.php.
+        $context = \context_user::instance($USER->id);
+        self::validate_context($context);
+        $PAGE->set_url('/calendar/');
+
         if ($courseid != SITEID && !empty($courseid)) {
             // Course ID must be valid and existing.
             $course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST);
             $courses = [$course->id => $course];
+
+            $coursecat = \coursecat::get($course->category);
+            $category = $coursecat->get_db_record();
         } else {
             $course = get_site();
             $courses = calendar_get_default_courses();
+            $category = null;
+
+            if ($categoryid) {
+                self::validate_context(context_coursecat::instance($categoryid));
+                $ids = [$categoryid];
+                $category = \coursecat::get($categoryid);
+                $ids += $category->get_parents();
+                $categories = \coursecat::get_many($ids);
+                $courses = array_filter($courses, function($course) use ($categories) {
+                    return array_search($course->category, $categories) !== false;
+                });
+                $category = $category->get_db_record();
+            }
         }
 
-        // TODO: Copy what we do in calendar/view.php.
-        $context = \context_user::instance($USER->id);
-        self::validate_context($context);
-
         $type = \core_calendar\type_factory::get_calendar_instance();
 
         $time = $type->convert_to_timestamp($year, $month, 1);
         $calendar = new calendar_information(0, 0, 0, $time);
-        $calendar->prepare_for_view($course, $courses);
+        $calendar->set_sources($course, $courses, $category);
 
         list($data, $template) = calendar_get_view($calendar, 'month', $params['includenavigation']);
 
@@ -932,6 +951,7 @@ class core_calendar_external extends external_api {
                 'year' => new external_value(PARAM_INT, 'Month to be viewed', VALUE_REQUIRED),
                 'month' => new external_value(PARAM_INT, 'Year to be viewed', VALUE_REQUIRED),
                 'courseid' => new external_value(PARAM_INT, 'Course being viewed', VALUE_DEFAULT, SITEID, NULL_ALLOWED),
+                'categoryid' => new external_value(PARAM_INT, 'Category being viewed', VALUE_DEFAULT, null, NULL_ALLOWED),
                 'includenavigation' => new external_value(
                     PARAM_BOOL,
                     'Whether to show course navigation',
@@ -1031,4 +1051,61 @@ class core_calendar_external extends external_api {
             )
         );
     }
+
+    /**
+     * Get data for the monthly calendar view.
+     *
+     * @param   int     $courseid The course to be included
+     * @return  array
+     */
+    public static function get_calendar_upcoming_view($courseid) {
+        global $CFG, $DB, $USER, $PAGE;
+        require_once($CFG->dirroot."/calendar/lib.php");
+
+        // Parameter validation.
+        self::validate_parameters(self::get_calendar_upcoming_view_parameters(), [
+            'courseid' => $courseid,
+        ]);
+
+        if ($courseid != SITEID && !empty($courseid)) {
+            // Course ID must be valid and existing.
+            $course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST);
+            $courses = [$course->id => $course];
+        } else {
+            $course = get_site();
+            $courses = calendar_get_default_courses();
+        }
+
+        $context = \context_user::instance($USER->id);
+        self::validate_context($context);
+
+        $calendar = new calendar_information(0, 0, 0, time());
+        $calendar->set_sources($course, $courses);
+
+        list($data, $template) = calendar_get_view($calendar, 'upcoming');
+
+        return $data;
+    }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function get_calendar_upcoming_view_parameters() {
+        return new external_function_parameters(
+            [
+                'courseid' => new external_value(PARAM_INT, 'Course being viewed', VALUE_DEFAULT, SITEID, NULL_ALLOWED),
+            ]
+        );
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     */
+    public static function get_calendar_upcoming_view_returns() {
+        return \core_calendar\external\calendar_upcoming_exporter::get_read_structure();
+    }
 }
index d511497..a74ddcf 100644 (file)
@@ -27,6 +27,8 @@ if (!defined('MOODLE_INTERNAL')) {
     die('Direct access to this script is forbidden.');    ///  It must be included from a Moodle page
 }
 
+require_once($CFG->libdir . '/coursecatlib.php');
+
 /**
  *  These are read by the administration component to provide default values
  */
@@ -89,6 +91,10 @@ define('CALENDAR_EVENT_GROUP', 4);
  */
 define('CALENDAR_EVENT_USER', 8);
 
+/**
+ * CALENDAR_EVENT_COURSECAT - Course category calendar event types
+ */
+define('CALENDAR_EVENT_COURSECAT', 16);
 
 /**
  * CALENDAR_IMPORT_FROM_FILE - import the calendar from a file
@@ -312,7 +318,9 @@ class calendar_event {
         global $USER, $DB;
 
         $context = null;
-        if (isset($this->properties->courseid) && $this->properties->courseid > 0) {
+        if (isset($this->properties->categoryid) && $this->properties->categoryid > 0) {
+            $context = \context_coursecat::instance($this->properties->categoryid);
+        } else if (isset($this->properties->courseid) && $this->properties->courseid > 0) {
             $context = \context_course::instance($this->properties->courseid);
         } else if (isset($this->properties->course) && $this->properties->course > 0) {
             $context = \context_course::instance($this->properties->course);
@@ -360,8 +368,7 @@ class calendar_event {
             if ($this->editorcontext === null) {
                 // Switch on the event type to decide upon the appropriate context to use for this event.
                 $this->editorcontext = $this->properties->context;
-                if ($this->properties->eventtype != 'user' && $this->properties->eventtype != 'course'
-                    && $this->properties->eventtype != 'site' && $this->properties->eventtype != 'group') {
+                if (!calendar_is_valid_eventtype($this->properties->eventtype)) {
                     return clean_text($this->properties->description, $this->properties->format);
                 }
             }
@@ -461,6 +468,11 @@ class calendar_event {
                         $this->properties->groupid = 0;
                         $this->properties->userid = $USER->id;
                         break;
+                    case 'category':
+                        $this->properties->groupid = 0;
+                        $this->properties->category = 0;
+                        $this->properties->userid = $USER->id;
+                        break;
                     case 'group':
                         $this->properties->userid = $USER->id;
                         break;
@@ -752,6 +764,14 @@ class calendar_event {
                     // We have a course and are within the course context so we had
                     // better use the courses max bytes value.
                     $this->editoroptions['maxbytes'] = $course->maxbytes;
+                } else if ($properties->eventtype === 'category') {
+                    // First check the course is valid.
+                    \coursecat::get($properties->categoryid, MUST_EXIST, true);
+                    // Course context.
+                    $this->editorcontext = $this->properties->context;
+                    // We have a course and are within the course context so we had
+                    // better use the courses max bytes value.
+                    $this->editoroptions['maxbytes'] = $course->maxbytes;
                 } else {
                     // If we get here we have a custom event type as used by some
                     // modules. In this case the event will have been added by
@@ -896,8 +916,7 @@ class calendar_event {
             // Switch on the event type to decide upon the appropriate context to use for this event.
             $this->editorcontext = $this->properties->context;
 
-            if ($this->properties->eventtype != 'user' && $this->properties->eventtype != 'course'
-                && $this->properties->eventtype != 'site' && $this->properties->eventtype != 'group') {
+            if (!calendar_is_valid_eventtype($this->properties->eventtype)) {
                 // We don't have a context here, do a normal format_text.
                 return external_format_text($this->properties->description, $this->properties->format, $this->editorcontext->id);
             }
@@ -939,6 +958,12 @@ class calendar_information {
     /** @var int A course id */
     public $courseid = null;
 
+    /** @var array An array of categories */
+    public $categories = array();
+
+    /** @var int The current category */
+    public $categoryid = null;
+
     /** @var array An array of courses */
     public $courses = array();
 
@@ -989,7 +1014,7 @@ class calendar_information {
      * @return  $this
      */
     public function set_time($time = null) {
-        if ($time === null) {
+        if (empty($time)) {
             $this->time = time();
         } else {
             $this->time = $time;
@@ -1001,17 +1026,72 @@ class calendar_information {
     /**
      * Initialize calendar information
      *
+     * @deprecated 3.4
      * @param stdClass $course object
      * @param array $coursestoload An array of courses [$course->id => $course]
      * @param bool $ignorefilters options to use filter
      */
     public function prepare_for_view(stdClass $course, array $coursestoload, $ignorefilters = false) {
-        $this->courseid = $course->id;
+        debugging('The prepare_for_view() function has been deprecated. Please update your code to use set_sources()',
+                DEBUG_DEVELOPER);
+        $this->set_sources($course, $coursestoload);
+    }
+
+    /**
+     * Set the sources for events within the calendar.
+     *
+     * If no category is provided, then the category path for the current
+     * course will be used.
+     *
+     * @param   stdClass    $course The current course being viewed.
+     * @param   int[]       $courses The list of all courses currently accessible.
+     * @param   stdClass    $category The current category to show.
+     */
+    public function set_sources(stdClass $course, array $courses, stdClass $category = null) {
+        // A cousre must always be specified.
         $this->course = $course;
-        list($courses, $group, $user) = calendar_set_filters($coursestoload, $ignorefilters);
-        $this->courses = $courses;
+        $this->courseid = $course->id;
+
+        list($courseids, $group, $user) = calendar_set_filters($courses);
+        $this->courses = $courseids;
         $this->groups = $group;
         $this->users = $user;
+
+        // Do not show category events by default.
+        $this->categoryid = null;
+        $this->categories = null;
+
+        if (null !== $category && $category->id > 0) {
+            // A specific category was requested - set the current category, and include all parents of that category.
+            $category = \coursecat::get($category->id);
+            $this->categoryid = $category->id;
+
+            $this->categories = $category->get_parents();
+            $this->categories[] = $category->id;
+        } else if (SITEID === $this->courseid) {
+            // This is the site.
+            // Show categories for all courses the user has access to.
+            $this->categories = true;
+            $categories = [];
+            foreach ($courses as $course) {
+                if ($category = \coursecat::get($course->category, IGNORE_MISSING)) {
+                    $categories = array_merge($categories, $category->get_parents());
+                    $categories[] = $category->id;
+                }
+            }
+
+            // And all categories that the user can manage.
+            foreach (\coursecat::get_all() as $category) {
+                if (!$category->has_manage_capability()) {
+                    continue;
+                }
+
+                $categories = array_merge($categories, $category->get_parents());
+                $categories[] = $category->id;
+            }
+
+            $this->categories = array_unique($categories);
+        }
     }
 
     /**
@@ -1087,15 +1167,17 @@ class calendar_information {
  * @param boolean $withduration whether only events starting within time range selected
  *                              or events in progress/already started selected as well
  * @param boolean $ignorehidden whether to select only visible events or all events
+ * @param array|int|boolean $categories array of categories, category id or boolean for all/no course events
  * @return array $events of selected events or an empty array if there aren't any (or there was an error)
  */
-function calendar_get_events($tstart, $tend, $users, $groups, $courses, $withduration=true, $ignorehidden=true) {
+function calendar_get_events($tstart, $tend, $users, $groups, $courses,
+        $withduration = true, $ignorehidden = true, $categories = []) {
     global $DB;
 
     $whereclause = '';
     $params = array();
     // Quick test.
-    if (empty($users) && empty($groups) && empty($courses)) {
+    if (empty($users) && empty($groups) && empty($courses) && empty($categories)) {
         return array();
     }
 
@@ -1103,12 +1185,12 @@ function calendar_get_events($tstart, $tend, $users, $groups, $courses, $withdur
         // Events from a number of users
         if(!empty($whereclause)) $whereclause .= ' OR';
         list($insqlusers, $inparamsusers) = $DB->get_in_or_equal($users, SQL_PARAMS_NAMED);
-        $whereclause .= " (e.userid $insqlusers AND e.courseid = 0 AND e.groupid = 0)";
+        $whereclause .= " (e.userid $insqlusers AND e.courseid = 0 AND e.groupid = 0 AND e.categoryid = 0)";
         $params = array_merge($params, $inparamsusers);
     } else if($users === true) {
         // Events from ALL users
         if(!empty($whereclause)) $whereclause .= ' OR';
-        $whereclause .= ' (e.userid != 0 AND e.courseid = 0 AND e.groupid = 0)';
+        $whereclause .= ' (e.userid != 0 AND e.courseid = 0 AND e.groupid = 0 AND e.categoryid = 0)';
     } else if($users === false) {
         // No user at all, do nothing
     }
@@ -1137,6 +1219,21 @@ function calendar_get_events($tstart, $tend, $users, $groups, $courses, $withdur
         $whereclause .= ' (e.groupid = 0 AND e.courseid != 0)';
     }
 
+    if ((is_array($categories) && !empty($categories)) || is_numeric($categories)) {
+        if (!empty($whereclause)) {
+            $whereclause .= ' OR';
+        }
+        list($insqlcategories, $inparamscategories) = $DB->get_in_or_equal($categories, SQL_PARAMS_NAMED);
+        $whereclause .= " (e.groupid = 0 AND e.courseid = 0 AND e.categoryid $insqlcategories)";
+        $params = array_merge($params, $inparamscategories);
+    } else if ($categories === true) {
+        // Events from ALL categories.
+        if (!empty($whereclause)) {
+            $whereclause .= ' OR';
+        }
+        $whereclause .= ' (e.groupid = 0 AND e.courseid = 0 AND e.categoryid != 0)';
+    }
+
     // Security check: if, by now, we have NOTHING in $whereclause, then it means
     // that NO event-selecting clauses were defined. Thus, we won't be returning ANY
     // events no matter what. Allowing the code to proceed might return a completely
@@ -2056,7 +2153,10 @@ function calendar_edit_event_allowed($event, $manualedit = false) {
                 (has_capability('moodle/calendar:managegroupentries', $event->context)
                     && groups_is_member($event->groupid)));
     } else if (!empty($event->courseid)) {
-        // If groupid is not set, but course is set, it's definiely a course event.
+        // If groupid is not set, but course is set, it's definitely a course event.
+        return has_capability('moodle/calendar:manageentries', $event->context);
+    } else if (!empty($event->categoryid)) {
+        // If groupid is not set, but category is set, it's definitely a category event.
         return has_capability('moodle/calendar:manageentries', $event->context);
     } else if (!empty($event->userid) && $event->userid == $USER->id) {
         // If course is not set, but userid id set, it's a user event.
@@ -2237,7 +2337,8 @@ 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 + CALENDAR_EVENT_USER;
+    $default = CALENDAR_EVENT_GLOBAL + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP
+            + CALENDAR_EVENT_USER + CALENDAR_EVENT_COURSECAT;
     if ($persist === 0) {
         global $SESSION;
         if (!isset($SESSION->calendarshoweventtype)) {
@@ -2273,14 +2374,16 @@ function calendar_set_event_type_display($type, $display = null, $user = null) {
  * @param stdClass $allowed list of allowed edit for event  type
  * @param stdClass|int $course object of a course or course id
  * @param array $groups array of groups for the given course
+ * @param stdClass|int $category object of a category
  */
-function calendar_get_allowed_types(&$allowed, $course = null, $groups = null) {
+function calendar_get_allowed_types(&$allowed, $course = null, $groups = null, $category = null) {
     global $USER, $DB;
 
     $allowed = new \stdClass();
     $allowed->user = has_capability('moodle/calendar:manageownentries', \context_system::instance());
     $allowed->groups = false;
     $allowed->courses = false;
+    $allowed->categories = false;
     $allowed->site = has_capability('moodle/calendar:manageentries', \context_course::instance(SITEID));
     $getgroupsfunc = function($course, $context, $user) use ($groups) {
         if ($course->groupmode != NOGROUPS || !$course->groupmodeforce) {
@@ -2316,6 +2419,13 @@ function calendar_get_allowed_types(&$allowed, $course = null, $groups = null) {
             }
         }
     }
+
+    if (!empty($category)) {
+        $catcontext = \context_coursecat::instance($category->id);
+        if (has_capability('moodle/category:manage', $catcontext)) {
+            $allowed->categories = [$category->id => 1];
+        }
+    }
 }
 
 /**
@@ -2325,6 +2435,7 @@ function calendar_get_allowed_types(&$allowed, $course = null, $groups = null) {
  * The returned array will optionally have 5 keys:
  *      'user' : true if the logged in user can create user events
  *      'site' : true if the logged in user can create site events
+ *      'category' : array of course categories that the user can create events for
  *      'course' : array of courses that the user can create events for
  *      'group': array of groups that the user can create events for
  *      'groupcourses' : array of courses that the groups belong to (can
@@ -2333,7 +2444,7 @@ function calendar_get_allowed_types(&$allowed, $course = null, $groups = null) {
  * @return array The array of allowed types.
  */
 function calendar_get_all_allowed_types() {
-    global $CFG, $USER;
+    global $CFG, $USER, $DB;
 
     require_once($CFG->libdir . '/enrollib.php');
 
@@ -2349,6 +2460,10 @@ function calendar_get_all_allowed_types() {
         $types['site'] = true;
     }
 
+    if (coursecat::has_manage_capability_on_any()) {
+        $types['category'] = coursecat::make_categories_list('moodle/category:manage');
+    }
+
     // This function warms the context cache for the course so the calls
     // to load the course context in calendar_get_allowed_types don't result
     // in additional DB queries.
@@ -2401,7 +2516,7 @@ function calendar_user_can_add_event($course) {
 
     calendar_get_allowed_types($allowed, $course);
 
-    return (bool)($allowed->user || $allowed->groups || $allowed->courses || $allowed->site);
+    return (bool)($allowed->user || $allowed->groups || $allowed->courses || $allowed->category || $allowed->site);
 }
 
 /**
@@ -2426,6 +2541,8 @@ function calendar_add_event_allowed($event) {
     }
 
     switch ($event->eventtype) {
+        case 'category':
+            return has_capability('moodle/category:manage', $event->context);
         case 'course':
             return has_capability('moodle/calendar:manageentries', $event->context);
         case 'group':
@@ -2485,6 +2602,9 @@ function calendar_get_eventtype_choices($courseid) {
     if (!empty($allowed->courses)) {
         $choices['course'] = get_string('courseevents', 'calendar');
     }
+    if (!empty($allowed->categories)) {
+        $choices['category'] = get_string('categoryevents', 'calendar');
+    }
     if (!empty($allowed->groups) and is_array($allowed->groups)) {
         $choices['group'] = get_string('group');
     }
@@ -2505,6 +2625,8 @@ function calendar_add_subscription($sub) {
         $sub->courseid = $SITE->id;
     } else if ($sub->eventtype === 'group' || $sub->eventtype === 'course') {
         $sub->courseid = $sub->course;
+    } else if ($sub->eventtype === 'category') {
+        $sub->categoryid = $sub->category;
     } else {
         // User events.
         $sub->courseid = 0;
@@ -2956,11 +3078,12 @@ function core_calendar_user_preferences() {
  * @param boolean $ignorehidden whether to select only visible events or all events
  * @return array $events of selected events or an empty array if there aren't any (or there was an error)
  */
-function calendar_get_legacy_events($tstart, $tend, $users, $groups, $courses, $withduration = true, $ignorehidden = true) {
+function calendar_get_legacy_events($tstart, $tend, $users, $groups, $courses,
+        $withduration = true, $ignorehidden = true, $categories = []) {
     // Normalise the users, groups and courses parameters so that they are compliant with \core_calendar\local\api::get_events().
     // Existing functions that were using the old calendar_get_events() were passing a mixture of array, int, boolean for these
     // parameters, but with the new API method, only null and arrays are accepted.
-    list($userparam, $groupparam, $courseparam) = array_map(function($param) {
+    list($userparam, $groupparam, $courseparam, $categoryparam) = array_map(function($param) {
         // If parameter is true, return null.
         if ($param === true) {
             return null;
@@ -2978,7 +3101,7 @@ function calendar_get_legacy_events($tstart, $tend, $users, $groups, $courses, $
 
         // No normalisation required.
         return $param;
-    }, [$users, $groups, $courses]);
+    }, [$users, $groups, $courses, $categories]);
 
     $mapper = \core_calendar\local\event\container::get_event_mapper();
     $events = \core_calendar\local\api::get_events(
@@ -2993,6 +3116,7 @@ function calendar_get_legacy_events($tstart, $tend, $users, $groups, $courses, $
         $userparam,
         $groupparam,
         $courseparam,
+        $categoryparam,
         $withduration,
         $ignorehidden
     );
@@ -3029,8 +3153,9 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
         } else {
             $defaultlookahead = CALENDAR_DEFAULT_UPCOMING_LOOKAHEAD;
         }
-        $tstart = $type->convert_to_timestamp($date['year'], $date['mon'], 1);
-        $tend = $tstart + get_user_preferences('calendar_lookahead', $defaultlookahead);
+
+        $tstart = $type->convert_to_timestamp($date['year'], $date['mon'], $date['mday'], $date['hours']);
+        $tend = usergetmidnight($tstart + DAYSECS * $defaultlookahead + 3 * HOURSECS) - 1;
     } else {
         $tstart = $type->convert_to_timestamp($date['year'], $date['mon'], 1);
         $monthdays = $type->get_num_days_in_month($date['year'], $date['mon']);
@@ -3043,7 +3168,7 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
         }
     }
 
-    list($userparam, $groupparam, $courseparam) = array_map(function($param) {
+    list($userparam, $groupparam, $courseparam, $categoryparam) = array_map(function($param) {
         // If parameter is true, return null.
         if ($param === true) {
             return null;
@@ -3061,7 +3186,7 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
 
         // No normalisation required.
         return $param;
-    }, [$calendar->users, $calendar->groups, $calendar->courses]);
+    }, [$calendar->users, $calendar->groups, $calendar->courses, $calendar->categories]);
 
     $events = \core_calendar\local\api::get_events(
         $tstart,
@@ -3075,13 +3200,19 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
         $userparam,
         $groupparam,
         $courseparam,
+        $categoryparam,
         true,
         true,
         function ($event) {
             if ($proxy = $event->get_course_module()) {
                 $cminfo = $proxy->get_proxied_instance();
                 return $cminfo->uservisible;
+            }
+
+            if ($proxy = $event->get_category()) {
+                $category = $proxy->get_proxied_instance();
 
+                return $category->is_uservisible();
             }
 
             return true;
@@ -3104,6 +3235,10 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
         $day = new \core_calendar\external\day_exporter($calendar, $daydata, $related);
         $data = $day->export($renderer);
         $template = 'core_calendar/day_detailed';
+    } else if ($view == "upcoming") {
+        $upcoming = new \core_calendar\external\calendar_upcoming_exporter($calendar, $related);
+        $data = $upcoming->export($renderer);
+        $template = 'core_calendar/calendar_upcoming';
     }
 
     return [$data, $template];
@@ -3123,6 +3258,7 @@ function calendar_output_fragment_event_form($args) {
     $eventid = isset($args['eventid']) ? clean_param($args['eventid'], PARAM_INT) : null;
     $starttime = isset($args['starttime']) ? clean_param($args['starttime'], PARAM_INT) : null;
     $courseid = isset($args['courseid']) ? clean_param($args['courseid'], PARAM_INT) : null;
+    $categoryid = isset($args['categoryid']) ? clean_param($args['categoryid'], PARAM_INT) : null;
     $event = null;
     $hasformdata = isset($args['formdata']) && !empty($args['formdata']);
     $context = \context_user::instance($USER->id);
@@ -3155,6 +3291,9 @@ function calendar_output_fragment_event_form($args) {
             $data['eventtype'] = 'course';
             $data['courseid'] = $courseid;
             $data['groupcourseid'] = $courseid;
+        } else if (!empty($categoryid)) {
+            $data['eventtype'] = 'category';
+            $data['categoryid'] = $categoryid;
         }
         $mform->set_data($data);
     } else {
@@ -3262,6 +3401,7 @@ function calendar_get_footer_options($calendar) {
 function calendar_get_filter_types() {
     $types = [
         'site',
+        'category',
         'course',
         'group',
         'user',
@@ -3274,3 +3414,20 @@ function calendar_get_filter_types() {
         ];
     }, $types);
 }
+
+/**
+ * Check whether the specified event type is valid.
+ *
+ * @param string $type
+ * @return bool
+ */
+function calendar_is_valid_eventtype($type) {
+    $validtypes = [
+        'user',
+        'group',
+        'course',
+        'category',
+        'site',
+    ];
+    return in_array($type, $validtypes);
+}
index 484265e..906dbfe 100644 (file)
@@ -232,46 +232,6 @@ class core_calendar_renderer extends plugin_renderer_base {
         return html_writer::tag('div', $eventhtml, array('class' => 'event', 'id' => 'event_' . $event->id));
     }
 
-    /**
-     * Displays upcoming events
-     *
-     * @param calendar_information $calendar
-     * @param int $futuredays
-     * @param int $maxevents
-     * @return string
-     */
-    public function show_upcoming_events(calendar_information $calendar, $futuredays, $maxevents, moodle_url $returnurl = null) {
-
-        if ($returnurl === null) {
-            $returnurl = $this->page->url;
-        }
-
-        $events = calendar_get_upcoming($calendar->courses, $calendar->groups, $calendar->users,
-            $futuredays, $maxevents);
-
-        $output  = html_writer::start_tag('div', array('class'=>'header'));
-        $output .= $this->course_filter_selector($returnurl, get_string('upcomingeventsfor', 'calendar'));
-        if (calendar_user_can_add_event($calendar->course)) {
-            $output .= $this->add_event_button($calendar->course->id);
-        }
-        $output .= html_writer::end_tag('div');
-
-        if ($events) {
-            $output .= html_writer::start_tag('div', array('class' => 'eventlist'));
-            foreach ($events as $event) {
-                // Convert to calendar_event object so that we transform description accordingly.
-                $event = new calendar_event($event);
-                $event->calendarcourseid = $calendar->courseid;
-                $output .= $this->event($event);
-            }
-            $output .= html_writer::end_tag('div');
-        } else {
-            $output .= html_writer::span(get_string('noupcomingevents', 'calendar'), 'calendar-information calendar-no-results');
-        }
-
-        return $output;
-    }
-
     /**
      * Displays a course filter selector
      *
diff --git a/calendar/templates/calendar_upcoming.mustache b/calendar/templates/calendar_upcoming.mustache
new file mode 100644 (file)
index 0000000..3c873ee
--- /dev/null
@@ -0,0 +1,41 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template calendar/calendar_upcoming
+
+    Calendar upcoming view.
+
+    The purpose of this template is to render the calendar upcoming view.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div id="calendar-upcoming-{{uniqid}}" data-template="core_calendar/upcoming_detailed">
+    {{> core_calendar/upcoming_detailed}}
+</div>
+{{#js}}
+require(['jquery', 'core_calendar/calendar_view'], function($, CalendarView) {
+    CalendarView.init($("#calendar-upcoming-{{uniqid}}"), 'upcoming');
+});
+{{/js}}
index 3f4f31a..91d0751 100644 (file)
     {
     }
 }}
-<div class="event">
+<div{{!
+    }} data-type="event"{{!
+    }} data-course-id="{{course.id}}"{{!
+    }} data-event-id="{{id}}"{{!
+    }} class="event"{{!
+    }} data-eventtype-{{calendareventtype}}="1"{{!
+    }} data-event-title="{{name}}"{{!
+    }} data-event-count="{{eventcount}}"{{!
+    }}>
     <div class="card">
         <div class="box card-header clearfix p-y-1">
             <div class="commands pull-xs-right">
                 {{#canedit}}
                     {{#candelete}}
-                        <a href="{{deleteurl}}">
+                        <a href="{{deleteurl}}" data-action="delete">
                             {{#pix}}t/delete, core, {{#str}}delete{{/str}}{{/pix}}
                         </a>
                     {{/candelete}}
-                    <a href="{{editurl}}">
+                    <a href="{{editurl}}" data-action="edit">
                         {{#pix}}t/edit, core, {{#str}}edit{{/str}}{{/pix}}
                     </a>
                 {{/canedit}}
@@ -65,4 +73,4 @@
             {{/groupname}}
         </div>
     </div>
-</div>
\ No newline at end of file
+</div>
index dec4fa2..7b6f28d 100644 (file)
@@ -31,7 +31,7 @@
     {
     }
 }}
-<div class="eventlist">
+<div class="eventlist m-y-1">
     {{#events}}
         {{> core_calendar/event_item }}
     {{/events}}
index b139b69..2a67233 100644 (file)
@@ -32,7 +32,7 @@
     }} data-region="summary-modal-container"{{!
     }} data-event-id="{{id}}"{{!
     }} data-event-title="{{name}}"{{!
-    }} data-event-event-count="{{eventcount}}"{{!
+    }} data-event-count="{{eventcount}}"{{!
     }} data-event-="{{repeatid}}"{{!
     }} data-action-event="{{isactionevent}}"{{!
     }} data-edit-url="{{editurl}}"{{!
@@ -46,6 +46,9 @@
     {{/description}}
     <h4>{{#str}} eventtype, core_calendar {{/str}}</h4>
     {{eventtype}}
+    {{#iscategoryevent}}
+        <div>{{{category.nestedname}}}</div>
+    {{/iscategoryevent}}
     {{#iscourseevent}}
         <div><a href="{{url}}">{{course.fullname}}</a></div>
     {{/iscourseevent}}
index 626470d..c50e643 100644 (file)
@@ -31,8 +31,9 @@
     {
     }
 }}
-{{#filter_selector}}
 <div class="header">
-    {{{filter_selector}}}
+    {{#filter_selector}}
+        {{{filter_selector}}}
+    {{/filter_selector}}
+    {{> core_calendar/add_event_button}}
 </div>
-{{/filter_selector}}
index baa9013..a74fb24 100644 (file)
 }}
 <div{{!
     }} class="calendarwrapper"{{!
-    }} data-courseid="{{courseid}}"{{!
+    }}{{#courseid}} data-courseid="{{courseid}}"{{/courseid}}{{!
+    }}{{#categoryid}} data-categoryid="{{categoryid}}"{{/categoryid}}{{!
+    }} data-context-id="{{defaulteventcontext}}"{{!
     }} data-month="{{date.mon}}"{{!
     }} data-year="{{date.year}}"{{!
     }}>
     {{> core_calendar/header }}
-    {{> core_calendar/add_event_button}}
     {{> core_calendar/month_navigation }}
     {{> core/overlay_loading}}
     <table id="month-detailed-{{uniqid}}" class="calendarmonth calendartable card-deck m-b-0">
index bc180c7..6f6773f 100644 (file)
@@ -35,6 +35,7 @@
     }} id="month-mini-{{date.year}}-{{date.month}}-{{uniqid}}"{{!
     }} class="calendarwrapper"{{!
     }} data-courseid="{{courseid}}"{{!
+    }} data-categoryid="{{categoryid}}"{{!
     }} data-month="{{date.mon}}"{{!
     }} data-year="{{date.year}}"{{!
     }}>
diff --git a/calendar/templates/upcoming_detailed.mustache b/calendar/templates/upcoming_detailed.mustache
new file mode 100644 (file)
index 0000000..a1b070b
--- /dev/null
@@ -0,0 +1,38 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template calendar/upcoming_detailed
+
+    Calendar upcoming detailed.
+
+    The purpose of this template is to render the calendar upcoming detailed view.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div class="calendarwrapper" data-view="upcoming" data-context-id="{{defaulteventcontext}}" data-courseid="{{courseid}}">
+    {{> core_calendar/header}}
+    {{> core/overlay_loading}}
+    {{> core_calendar/event_list }}
+</div>
index 70bbf90..acb8643 100644 (file)
@@ -108,6 +108,10 @@ class core_calendar_action_event_test_event implements event_interface {
         return new event_description('asdf', 1);
     }
 
+    public function get_category() {
+        return new \stdClass();
+    }
+
     public function get_course() {
         return new \stdClass();
     }
index f1d6153..300588c 100644 (file)
@@ -60,8 +60,8 @@ Feature: Perform basic calendar functionality
     When I am on "Course 1" course homepage
     And I follow "This month"
     And I click on "Really awesome event!" "link"
-    And "Course 1" "link" should exist in the ".modal-body" "css_element"
-    And I click on ".close" "css_element"
+    And "Course 1" "link" should exist in the "Really awesome event!" "dialogue"
+    And I click on "Close" "button"
     And I log out
     And I log in as "student2"
     And I follow "This month"
diff --git a/calendar/tests/behat/category_events.feature b/calendar/tests/behat/category_events.feature
new file mode 100644 (file)
index 0000000..17a4538
--- /dev/null
@@ -0,0 +1,100 @@
+@core @core_calendar
+Feature: Course Category Events
+  In order to inform multiple courses of shared events
+  As a manager
+  I need to create catgory events
+
+  Background:
+    Given the following "users" exist:
+      | username    | firstname     | lastname      | email                     |
+      | managera    | Manager       | A             | managera@example.com      |
+      | managera1   | Manager       | A1            | managera1@example.com     |
+      | managera2   | Manager       | A2            | managera2@example.com     |
+      | teachera1i  | Teacher       | A1i           | teachera1i@example.com    |
+      | managerb    | Manager       | B             | managerb@example.com      |
+      | managerb1   | Manager       | B1            | managerb1@example.com     |
+      | managerb2   | Manager       | B2            | managerb2@example.com     |
+      | teacherb1i  | Teacher       | B1i           | teacherb1i@example.com    |
+      | student1    | Student       | 1             | student1@example.com      |
+      | student2    | Student       | 2             | student2@example.com      |
+    And the following "categories" exist:
+      | name            | idnumber      | category  |
+      | Year            | year          |           |
+      | Faculty A       | faculty-a     | year      |
+      | Faculty B       | faculty-b     | year      |
+      | Department A1   | department-a1 | faculty-a |
+      | Department A2   | department-a2 | faculty-a |
+      | Department B1   | department-b1 | faculty-b |
+      | Department B2   | department-b2 | faculty-b |
+    And the following "courses" exist:
+      | fullname    | shortname | idnumber     | format        | category          |
+      | Course A1i  | A1i       | A1i          | topics        | department-a1     |
+      | Course A2i  | A2i       | A2i          | topics        | department-a2     |
+      | Course B1i  | B1i       | B1i          | topics        | department-b1     |
+      | Course B2i  | B2i       | B2i          | topics        | department-b2     |
+    And the following "role assigns" exist:
+      | user        | role      | contextlevel  | reference         |
+      | managera    | manager   | Category      | faculty-a         |
+      | managera1   | manager   | Category      | department-a1     |
+      | managerb    | manager   | Category      | faculty-b         |
+      | managerb1   | manager   | Category      | department-b1     |
+    And the following "course enrolments" exist:
+      | user        | course    | role              |
+      | teachera1i  | A1i       | editingteacher    |
+      | teacherb1i  | B1i       | editingteacher    |
+      | student1    | A1i       | student           |
+      | student1    | A2i       | student           |
+      | student2    | B1i       | student           |
+      | student2    | B2i       | student           |
+    And the following "events" exist:
+      | name        | eventtype |
+      | Site event  | global    |
+    And the following "events" exist:
+      | name        | eventtype | course |
+      | CA1i event  | course    | A1i    |
+      | CA2i event  | course    | A2i    |
+      | CB1i event  | course    | B1i    |
+      | CB2i event  | course    | B2i    |
+    And the following "events" exist:
+      | name        | eventtype | category          |
+      | FA event    | category  | faculty-a         |
+      | DA1 event   | category  | department-a1     |
+      | DA2 event   | category  | department-a1     |
+      | FB event    | category  | faculty-b         |
+      | DB1 event   | category  | department-b1     |
+      | DB2 event   | category  | department-b1     |
+
+  @javascript
+  Scenario: Manager of a Category can see all child and parent events in their category
+    Given I log in as "managera"
+    When I navigate to "Calendar" node in "Site pages"
+    Then I should see "FA event"
+    And  I should see "DA1 event"
+    And  I should see "DA2 event"
+    And  I should not see "FB event"
+    And  I should not see "DB1 event"
+    And  I should not see "DB2 event"
+    And  I log out
+    Given I log in as "managerb"
+    When I navigate to "Calendar" node in "Site pages"
+    Then I should see "FB event"
+    And  I should see "DB1 event"
+    And  I should see "DB2 event"
+    And  I should not see "FA event"
+    And  I should not see "DA1 event"
+    And  I should not see "DA2 event"
+
+  @javascript
+  Scenario: Users enrolled in a course can see all child and parent events in their category
+    Given I log in as "student1"
+    When I navigate to "Calendar" node in "Site pages"
+    Then I should see "FA event"
+    And  I should see "DA1 event"
+    And  I should see "DA2 event"
+    And  I should see "CA1i event"
+    And  I should see "CA2i event"
+    And  I should not see "FB event"
+    And  I should not see "DB1 event"
+    And  I should not see "DB2 event"
+    And  I should not see "CB1i event"
+    And  I should not see "CB2i event"
index e21692e..3298187 100644 (file)
@@ -240,6 +240,84 @@ class core_calendar_container_testcase extends advanced_testcase {
         $this->assertNull($event);
     }
 
+    /**
+     * Test that the event factory deals with invisible categorys as an admin.
+     */
+    public function test_event_factory_when_category_visibility_is_toggled_as_admin() {
+        // Create a hidden category.
+        $category = $this->getDataGenerator()->create_category(['visible' => 0]);
+
+        $eventdata = [
+                'categoryid' => $category->id,
+                'eventtype' => 'category',
+            ];
+        $legacyevent = $this->create_event($eventdata);
+
+        $dbrow = $this->get_dbrow_from_skeleton((object) $eventdata);
+        $dbrow->id = $legacyevent->id;
+
+        $factory = \core_calendar\local\event\container::get_event_factory();
+        $event = $factory->create_instance($dbrow);
+
+        // Module is still visible to admins even if the category is invisible.
+        $this->assertInstanceOf(event_interface::class, $event);
+    }
+
+    /**
+     * Test that the event factory deals with invisible categorys as an user.
+     */
+    public function test_event_factory_when_category_visibility_is_toggled_as_user() {
+        // Create a hidden category.
+        $category = $this->getDataGenerator()->create_category(['visible' => 0]);
+
+        $eventdata = [
+                'categoryid' => $category->id,
+                'eventtype' => 'category',
+            ];
+        $legacyevent = $this->create_event($eventdata);
+
+        $dbrow = $this->get_dbrow_from_skeleton((object) $eventdata);
+        $dbrow->id = $legacyevent->id;
+
+        // Use a standard user.
+        $user = $this->getDataGenerator()->create_user();
+
+        // Set the user to the student.
+        $this->setUser($user);
+
+        $factory = \core_calendar\local\event\container::get_event_factory();
+        $event = $factory->create_instance($dbrow);
+
+        // Module is invisible to non-privileged users.
+        $this->assertNull($event);
+    }
+
+    /**
+     * Test that the event factory deals with invisible categorys as an guest.
+     */
+    public function test_event_factory_when_category_visibility_is_toggled_as_guest() {
+        // Create a hidden category.
+        $category = $this->getDataGenerator()->create_category(['visible' => 0]);
+
+        $eventdata = [
+                'categoryid' => $category->id,
+                'eventtype' => 'category',
+            ];
+        $legacyevent = $this->create_event($eventdata);
+
+        $dbrow = $this->get_dbrow_from_skeleton((object) $eventdata);
+        $dbrow->id = $legacyevent->id;
+
+        // Set the user to the student.
+        $this->setGuestUser();
+
+        $factory = \core_calendar\local\event\container::get_event_factory();
+        $event = $factory->create_instance($dbrow);
+
+        // Module is invisible to guests.
+        $this->assertNull($event);
+    }
+
     /**
      * Test that the event factory deals with completion related events properly.
      */
@@ -264,6 +342,7 @@ class core_calendar_container_testcase extends advanced_testcase {
         $event->userid = 1;
         $event->modulename = 'assign';
         $event->instance = $assign->id;
+        $event->categoryid = 0;
         $event->courseid = $course->id;
         $event->groupid = 0;
         $event->timestart = time();
@@ -312,6 +391,7 @@ class core_calendar_container_testcase extends advanced_testcase {
         $event->userid = $user->id;
         $event->modulename = 'lesson';
         $event->instance = $lesson->id;
+        $event->categoryid = 0;
         $event->courseid = $course->id;
         $event->groupid = 0;
         $event->timestart = time();
@@ -397,6 +477,7 @@ class core_calendar_container_testcase extends advanced_testcase {
                     'name' => 'Test event',
                     'description' => 'Hello',
                     'format' => 1,
+                    'categoryid' => 0,
                     'courseid' => 1,
                     'groupid' => 0,
                     'userid' => 1,
@@ -418,6 +499,7 @@ class core_calendar_container_testcase extends advanced_testcase {
                     'name' => 'Test event',
                     'description' => 'Hello',
                     'format' => 1,
+                    'categoryid' => 0,
                     'courseid' => 1,
                     'groupid' => 1,
                     'userid' => 1,
@@ -459,4 +541,38 @@ class core_calendar_container_testcase extends advanced_testcase {
         $event = new calendar_event($record);
         return $event->create($record, false);
     }
+
+    /**
+     * Pad out a basic DB row with basic information.
+     *
+     * @param   \stdClass   $skeleton the current skeleton
+     * @return  \stdClass
+     */
+    protected function get_dbrow_from_skeleton($skeleton) {
+        $dbrow = (object) [
+            'name' => 'Name',
+            'description' => 'Description',
+            'format' => 1,
+            'categoryid' => 0,
+            'courseid' => 0,
+            'groupid' => 0,
+            'userid' => 0,
+            'repeatid' => 0,
+            'modulename' => '',
+            'instance' => 0,
+            'eventtype' => 'user',
+            'timestart' => 1486396800,
+            'timeduration' => 0,
+            'timesort' => 1486396800,
+            'visible' => 1,
+            'timemodified' => 1485793098,
+            'subscriptionid' => null
+        ];
+
+        foreach ((array) $skeleton as $key => $value) {
+            $dbrow->$key = $value;
+        }
+
+        return $dbrow;
+    }
 }
diff --git a/calendar/tests/coursecat_proxy_test.php b/calendar/tests/coursecat_proxy_test.php
new file mode 100644 (file)
index 0000000..c00fe47
--- /dev/null
@@ -0,0 +1,64 @@
+<?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/>.
+
+/**
+ * coursecat_proxy tests.
+ *
+ * @package     core_calendar
+ * @copyright   2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_calendar\local\event\proxies\coursecat_proxy;
+
+/**
+ * coursecat_proxy testcase.
+ *
+ * @copyright   2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_calendar_coursecat_proxy_testcase extends advanced_testcase {
+
+    public function test_valid_coursecat() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $name = '2027-2028 Academic Year';
+        $generator = $this->getDataGenerator();
+        $category = $generator->create_category([
+                'name' => $name,
+            ]);
+        cache_helper::purge_by_event('changesincoursecat');
+
+        // Fetch the proxy.
+        $startreads = $DB->perf_get_reads();
+        $proxy = new coursecat_proxy($category->id);
+        $this->assertInstanceOf(coursecat_proxy::class, $proxy);
+        $this->assertEquals(0, $DB->perf_get_reads() - $startreads);
+
+        // Fetch the ID - this is known and doesn't require a cache read.
+        $this->assertEquals($category->id, $proxy->get('id'));
+        $this->assertEquals(0, $DB->perf_get_reads() - $startreads);
+
+        // Fetch the name - not known, and requires a read.
+        $this->assertEquals($name, $proxy->get('name'));
+        $this->assertEquals(1, $DB->perf_get_reads() - $startreads);
+
+        $this->assertInstanceOf('coursecat', $proxy->get_proxied_instance());
+    }
+}
index 2cd22ea..9375126 100644 (file)
@@ -114,6 +114,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
                 'name' => 'test',
                 'description' => 'Test description',
                 'format' => 2,
+                'categoryid' => 0,
                 'courseid' => 1,
                 'groupid' => 1,
                 'userid' => 1,
@@ -162,6 +163,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
                 'name' => 'test',
                 'description' => 'Test description',
                 'format' => 2,
+                'categoryid' => 0,
                 'courseid' => 1,
                 'groupid' => 1,
                 'userid' => 1,
@@ -210,6 +212,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
                 'name' => 'test',
                 'description' => 'Test description',
                 'format' => 2,
+                'categoryid' => 0,
                 'courseid' => 1,
                 'groupid' => 1,
                 'userid' => 1,
@@ -258,6 +261,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
                 'name' => 'test',
                 'description' => 'Test description',
                 'format' => 2,
+                'categoryid' => 0,
                 'courseid' => $course->id,
                 'groupid' => 1,
                 'userid' => 1,
@@ -312,6 +316,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
                 'name' => 'test',
                 'description' => 'Test description',
                 'format' => 2,
+                'categoryid' => 0,
                 'courseid' => 0,
                 'groupid' => 1,
                 'userid' => 1,
@@ -345,6 +350,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
                     'name' => 'Test event',
                     'description' => 'Hello',
                     'format' => 1,
+                    'categoryid' => 0,
                     'courseid' => 1,
                     'groupid' => 1,
                     'userid' => 1,
@@ -378,6 +384,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
                     'name' => 'Test event',
                     'description' => 'Hello',
                     'format' => 1,
+                    'categoryid' => 0,
                     'courseid' => 1,
                     'groupid' => 1,
                     'userid' => 1,
@@ -411,6 +418,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
                     'name' => 'Test event',
                     'description' => 'Hello',
                     'format' => 1,
+                    'categoryid' => 0,
                     'courseid' => 1,
                     'groupid' => 1,
                     'userid' => 1,
index 0071a50..8294651 100644 (file)
@@ -202,6 +202,10 @@ class event_mapper_test_action_event implements action_event_interface {
         return $this->event->get_description();
     }
 
+    public function get_category() {
+        return $this->event->get_category();
+    }
+
     public function get_course() {
         return $this->event->get_course();
     }
@@ -255,6 +259,11 @@ class event_mapper_test_action_event implements action_event_interface {
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class event_mapper_test_event implements event_interface {
+    /**
+     * @var proxy_interface $categoryproxy Category proxy.
+     */
+    protected $categoryproxy;
+
     /**
      * @var proxy_interface $courseproxy Course proxy.
      */
@@ -312,6 +321,10 @@ class event_mapper_test_event implements event_interface {
         return new event_description('asdf', 1);
     }
 
+    public function get_category() {
+        return $this->categoryproxy;
+    }
+
     public function get_course() {
         return $this->courseproxy;
     }
index b2e5d9b..8b28046 100644 (file)
@@ -26,6 +26,7 @@ defined('MOODLE_INTERNAL') || die();
 
 use core_calendar\local\event\entities\event;
 use core_calendar\local\event\proxies\std_proxy;
+use core_calendar\local\event\proxies\coursecat_proxy;
 use core_calendar\local\event\value_objects\event_description;
 use core_calendar\local\event\value_objects\event_times;
 use core_calendar\local\event\entities\event_collection_interface;
@@ -48,6 +49,7 @@ class core_calendar_event_testcase extends advanced_testcase {
             $constructorparams['id'],
             $constructorparams['name'],
             $constructorparams['description'],
+            $constructorparams['category'],
             $constructorparams['course'],
             $constructorparams['group'],
             $constructorparams['user'],
@@ -82,6 +84,7 @@ class core_calendar_event_testcase extends advanced_testcase {
                     'id' => 1,
                     'name' => 'Test event 1',
                     'description' => new event_description('asdf', 1),
+                    'category' => new coursecat_proxy(0),
                     'course' => new std_proxy(1, $lamecallable),
                     'group' => new std_proxy(1, $lamecallable),
                     'user' => new std_proxy(1, $lamecallable),
index ea3edfd..f60d57f 100644 (file)
@@ -1600,7 +1600,7 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
     public function test_submit_create_update_form_create_site_event() {
         $generator = $this->getDataGenerator();
         $user = $generator->create_user();
-        $context = context_course::instance(SITEID);
+        $context = context_system::instance();
         $roleid = $generator->create_role();
         $timestart = new DateTime();
         $interval = new DateInterval("P1D"); // One day.
index f8e08fb..2c3b461 100644 (file)
@@ -32,6 +32,7 @@ use core_calendar\local\event\entities\action_event;
 use core_calendar\local\event\entities\event;
 use core_calendar\local\event\entities\repeat_event_collection;
 use core_calendar\local\event\proxies\std_proxy;
+use core_calendar\local\event\proxies\coursecat_proxy;
 use core_calendar\local\event\proxies\cm_info_proxy;
 use core_calendar\local\event\value_objects\action;
 use core_calendar\local\event\value_objects\event_description;
@@ -108,6 +109,7 @@ class action_event_test_factory implements event_factory_interface {
             $record->id,
             $record->name,
             new event_description($record->description, $record->format),
+            new coursecat_proxy($record->categoryid),
             new std_proxy($record->courseid, function($id) {
                 $course = new \stdClass();
                 $course->id = $id;
index 82d7794..9d8b137 100644 (file)
@@ -277,4 +277,69 @@ class core_calendar_raw_event_retrieval_strategy_testcase extends advanced_testc
         $events = $retrievalstrategy->get_raw_events();
         $this->assertCount(3, $events);
     }
+
+    /**
+     * Test retrieval strategy with category specifications.
+     */
+    public function test_get_raw_events_category() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $retrievalstrategy = new raw_event_retrieval_strategy();
+        $generator = $this->getDataGenerator();
+        $category1 = $generator->create_category();
+        $category2 = $generator->create_category();
+        $events = [
+            [
+                'name' => 'E1',
+                'eventtype' => 'category',
+                'description' => '',
+                'format' => 1,
+                'categoryid' => $category1->id,
+                'userid' => 2,
+                'timestart' => time(),
+            ],
+            [
+                'name' => 'E2',
+                'eventtype' => 'category',
+                'description' => '',
+                'format' => 1,
+                'categoryid' => $category2->id,
+                'userid' => 2,
+                'timestart' => time() + 1,
+            ],
+        ];
+
+        foreach ($events as $event) {
+            calendar_event::create($event, false);
+        }
+
+        // Get all events.
+        $events = $retrievalstrategy->get_raw_events(null, null, null, null);
+        $this->assertCount(2, $events);
+
+        $event = array_shift($events);
+        $this->assertEquals('E1', $event->name);
+        $event = array_shift($events);
+        $this->assertEquals('E2', $event->name);
+
+        // Get events for C1 events.
+        $events = $retrievalstrategy->get_raw_events(null, null, null, [$category1->id]);
+        $this->assertCount(1, $events);
+
+        $event = array_shift($events);
+        $this->assertEquals('E1', $event->name);
+
+        // Get events for C2 events.
+        $events = $retrievalstrategy->get_raw_events(null, null, null, [$category2->id]);
+        $this->assertCount(1, $events);
+
+        $event = array_shift($events);
+        $this->assertEquals('E2', $event->name);
+
+        // Get events for several categories.
+        $events = $retrievalstrategy->get_raw_events(null, null, null, [$category1->id, $category2->id]);
+        $this->assertCount(2, $events);
+    }
 }
+
index f59fdd9..ec6320c 100644 (file)
@@ -30,6 +30,7 @@ require_once($CFG->dirroot . '/calendar/lib.php');
 
 use core_calendar\local\event\entities\event;
 use core_calendar\local\event\entities\repeat_event_collection;
+use core_calendar\local\event\proxies\coursecat_proxy;
 use core_calendar\local\event\proxies\std_proxy;
 use core_calendar\local\event\value_objects\event_description;
 use core_calendar\local\event\value_objects\event_times;
@@ -161,6 +162,7 @@ class core_calendar_repeat_event_collection_event_test_factory implements event_
             $dbrow->id,
             $dbrow->name,
             new event_description($dbrow->description, $dbrow->format),
+            new coursecat_proxy($dbrow->categoryid),
             new std_proxy($dbrow->courseid, $identity),
             new std_proxy($dbrow->groupid, $identity),
             new std_proxy($dbrow->userid, $identity),
index ca28508..a6e2531 100644 (file)
@@ -872,7 +872,7 @@ class core_calendar_rrule_manager_testcase extends advanced_testcase {
         $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
         $expecteddate = new DateTime('first Monday of this month');
         // Move to the next interval's first Monday if the calculated start date is after this month's first Monday.
-        if ($expecteddate->getTimestamp() < $startdatetime->getTimestamp()) {
+        if ($expecteddate->getTimestamp() < $startdate->getTimestamp()) {
             $expecteddate->add($interval);
             $expecteddate->modify('first Monday of this month');
         }
index fe6a82e..52a713b 100644 (file)
@@ -49,6 +49,7 @@ require_once('../config.php');
 require_once($CFG->dirroot.'/course/lib.php');
 require_once($CFG->dirroot.'/calendar/lib.php');
 
+$categoryid = optional_param('category', null, PARAM_INT);
 $courseid = optional_param('course', SITEID, PARAM_INT);
 $view = optional_param('view', 'upcoming', PARAM_ALPHA);
 $time = optional_param('time', 0, PARAM_INT);
@@ -63,6 +64,10 @@ if ($courseid != SITEID) {
     $url->param('course', $courseid);
 }
 
+if ($categoryid) {
+    $url->param('categoryid', $categoryid);
+}
+
 if ($view !== 'upcoming') {
     $time = usergetmidnight($time);
     $url->param('view', $view);
@@ -76,18 +81,30 @@ if ($courseid != SITEID && !empty($courseid)) {
     // Course ID must be valid and existing.
     $course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST);
     $courses = array($course->id => $course);
-    $issite = false;
     navigation_node::override_active_url(new moodle_url('/course/view.php', array('id' => $course->id)));
 } else {
     $course = get_site();
     $courses = calendar_get_default_courses();
-    $issite = true;
+    if ($categoryid) {
+        $PAGE->set_category_by_id($categoryid);
+    } else {
+        $PAGE->set_context(context_system::instance());
+    }
+    if ($PAGE->context->contextlevel === CONTEXT_COURSECAT) {
+        // Restrict to categories, and their parents, and the courses that the user is enrolled in within those
+        // categories.
+        $categories = array_keys($PAGE->categories);
+        $courses = array_filter($courses, function($course) use ($categories) {
+            return array_search($course->category, $categories) !== false;
+        });
+        navigation_node::override_active_url(new moodle_url('/course/index.php', array('categoryid' => $categoryid)));
+    }
 }
 
 require_login($course, false);
 
 $calendar = new calendar_information(0, 0, 0, $time);
-$calendar->prepare_for_view($course, $courses);
+$calendar->set_sources($course, $courses, $PAGE->category);
 
 $pagetitle = '';
 
@@ -127,18 +144,8 @@ if ($view == 'day' || $view == 'upcoming') {
             echo $renderer->render_from_template($template, $data);
         break;
         case 'upcoming':
-            $defaultlookahead = CALENDAR_DEFAULT_UPCOMING_LOOKAHEAD;
-            if (isset($CFG->calendar_lookahead)) {
-                $defaultlookahead = intval($CFG->calendar_lookahead);
-            }
-            $lookahead = get_user_preferences('calendar_lookahead', $defaultlookahead);
-
-            $defaultmaxevents = CALENDAR_DEFAULT_UPCOMING_MAXEVENTS;
-            if (isset($CFG->calendar_maxevents)) {
-                $defaultmaxevents = intval($CFG->calendar_maxevents);
-            }
-            $maxevents = get_user_preferences('calendar_maxevents', $defaultmaxevents);
-            echo $renderer->show_upcoming_events($calendar, $lookahead, $maxevents);
+            list($data, $template) = calendar_get_view($calendar, $view);
+            echo $renderer->render_from_template($template, $data);
         break;
     }
 } else if ($view == 'month') {
index dcd708f..7595db0 100644 (file)
@@ -23,6 +23,8 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+defined('MOODLE_INTERNAL') || die();
+
 require_once("$CFG->libdir/externallib.php");
 
 class core_cohort_external extends external_api {
index beaa75d..6ca460c 100644 (file)
@@ -24,6 +24,8 @@
  * @since      Moodle 2.9
  */
 
+defined('MOODLE_INTERNAL') || die();
+
 require_once("$CFG->libdir/externallib.php");
 require_once("$CFG->dirroot/comment/lib.php");
 
index 48d89d7..094ffb4 100644 (file)
@@ -465,6 +465,13 @@ $CFG->admin = 'admin';
 //
 //      $CFG->disableupdateautodeploy = true;
 //
+// Use the following flag to disable the warning on the system notifications page
+// about present development libraries. This flag will not disable the warning within
+// the security overview report. Use this flag only if you really have prohibited web
+// access to the development libraries in your webserver configuration.
+//
+//      $CFG->disabledevlibdirscheck = true;
+//
 // Use the following flag to disable modifications to scheduled tasks
 // whilst still showing the state of tasks.
 //
index 3a135be..8b6cf0b 100644 (file)
@@ -114,5 +114,5 @@ Feature: Enrolments are synchronised with meta courses
     And I navigate to course participants
     # Suspended users can be unenrolled.
     When I click on "//a[@data-action='unenrol']" "xpath_element" in the "student2" "table_row"
-    And I click on "Unenrol" "button" in the "[data-region='modal']" "css_element"
+    And I click on "Unenrol" "button" in the "Unenrol" "dialogue"
     Then I should not see "Student 2" in the "participants" "table"
index feb67bf..35ddfce 100644 (file)
@@ -123,5 +123,5 @@ Feature: Users can auto-enrol themself in courses where self enrolment is allowe
     And I am on "Course 1" course homepage
     And I navigate to course participants
     When I click on "//a[@data-action='unenrol']" "xpath_element" in the "student1" "table_row"
-    And I click on "Unenrol" "button" in the "[data-region='modal']" "css_element"
+    And I click on "Unenrol" "button" in the "Unenrol" "dialogue"
     Then I should not see "Student 1" in the "participants" "table"
index 4e14624..6f3748c 100644 (file)
@@ -24,6 +24,8 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+defined('MOODLE_INTERNAL') || die();
+
 require_once("$CFG->libdir/externallib.php");
 require_once("$CFG->libdir/filelib.php");
 
index c3d2530..53d2dfc 100644 (file)
@@ -24,6 +24,8 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+defined('MOODLE_INTERNAL') || die();
+
 require_once("$CFG->libdir/externallib.php");
 
 /**
index b294490..60a865f 100644 (file)
@@ -440,7 +440,7 @@ $string['deleteunconfirmed'] = 'Delete not fully setup users after';
 $string['deleteuser'] = 'Delete user';
 $string['density'] = 'Density';
 $string['denyemailaddresses'] = 'Denied email domains';
-$string['devlibdirpresent'] = 'Directories with development libraries such as <em>vendor</em> or <em>node_modules</em> should not be present on public sites. See the <a href="{$a->moreinfourl}">security overview report</a> for more details.';
+$string['devlibdirpresent'] = 'Directories with development libraries, especially <em>/vendor</em> and <em>/node_modules</em>, should not be present on public sites. See the <a href="{$a->moreinfourl}">security overview report</a> for more details.';
 $string['development'] = 'Development';
 $string['devicedetectregex'] = 'Device detection regular expressions';
 $string['devicedetectregex_desc'] = '<p>By default, Moodle can detect devices of the type default (desktop PCs, laptops, etc), mobile (phones and small hand held devices), tablet (iPads, Android tablets) and legacy (Internet Explorer 6 users).  The theme selector can be used to apply separate themes to all of these.  This setting allows regular expressions that allow the detection of extra device types (these take precedence over the default types).</p>
@@ -981,6 +981,10 @@ $string['save'] = 'Save';
 $string['savechanges'] = 'Save changes';
 $string['scssinvalid'] = 'SCSS code is not valid, fails with: {$a}';
 $string['search'] = 'Search';
+$string['searchallavailablecourses'] = 'Searchable courses';
+$string['searchallavailablecourses_off'] = 'Search within enrolled courses only';
+$string['searchallavailablecourses_on'] = 'Search within all courses the user can access';
+$string['searchallavailablecourses_desc'] = 'In some situations the search engine may not work when searching across a large number of courses. Set to search only enrolled courses if you need to restrict the number of courses searched.';
 $string['searchalldeleted'] = 'All indexed contents have been deleted';
 $string['searchareaenabled'] = 'Search area enabled';
 $string['searchareadisabled'] = 'Search area disabled';
index a0bf7d0..3572a91 100644 (file)
@@ -32,6 +32,7 @@ $string['calendarheading'] = '{$a} Calendar';
 $string['calendarpreferences'] = 'Calendar preferences';
 $string['calendartypes'] = 'Calendar types';
 $string['calendarurl'] = 'Calendar URL: {$a}';
+$string['categoryevent'] = 'Category event';
 $string['clickhide'] = 'click to hide';
 $string['clickshow'] = 'click to show';
 $string['colcalendar'] = 'Calendar';
@@ -44,6 +45,7 @@ $string['confirmeventseriesdelete'] = 'The "{$a->name}" event is part of a serie
 $string['course'] = 'Course';
 $string['courseevent'] = 'Course event';
 $string['courseevents'] = 'Course events';
+$string['categoryevents'] = 'Category events';
 $string['courses'] = 'Courses';
 $string['customexport'] = 'Custom range ({$a->timestart} - {$a->timeend})';
 $string['daily'] = 'Daily';
@@ -97,6 +99,7 @@ $string['eventendtimewrapped'] = '{$a} (End time)';
 $string['eventinstanttime'] = 'Time';
 $string['eventkind'] = 'Type of event';
 $string['eventname'] = 'Event title';
+$string['eventnameandcategory'] = '{$a->category}: {$a->name}';
 $string['eventnameandcourse'] = '{$a->course}: {$a->name}';
 $string['eventnone'] = 'No events';
 $string['eventrepeat'] = 'Repeats';
@@ -141,6 +144,7 @@ $string['groupevent'] = 'Group event';
 $string['groupevents'] = 'Group events';
 $string['eventtypeglobal'] = 'global';
 $string['eventtypesite'] = 'global';
+$string['eventtypecategory'] = 'category';
 $string['eventtypecourse'] = 'course';
 $string['eventtypemodule'] = 'module';
 $string['eventtypegroup'] = 'group';
@@ -227,6 +231,7 @@ $string['tue'] = 'Tue';
 $string['tuesday'] = 'Tuesday';
 $string['typeclose'] = 'Close event';
 $string['typecourse'] = 'Course event';
+$string['typecategory'] = 'Category event';
 $string['typedue'] = 'Due event';
 $string['typegradingdue'] = 'Grading due event';
 $string['typegroup'] = 'Group event';
index 62a3d19..b672fc1 100644 (file)
Binary files a/lib/amd/build/chartjs-lazy.min.js and b/lib/amd/build/chartjs-lazy.min.js differ
index 033e609..d27968a 100644 (file)
-/*!\r
- * Chart.js\r
- * http://chartjs.org/\r
- * Version: 2.2.2\r
- *\r
- * Copyright 2016 Nick Downie\r
- * Released under the MIT license\r
- * https://github.com/chartjs/Chart.js/blob/master/LICENSE.md\r
- */\r
-\r
-/**\r
- * Description of import into Moodle:\r
- *\r
- * - Download from http://www.chartjs.org/docs/#getting-started-download-chart-js.\r
- * - Copy Chart.js to lib/amd/src/chartjs.js.\r
- * - Add these instructions to the file.\r
- * - Add the jshint ignore rules.\r
- * - Visit lib/tests/other/chartjstestpage.php to see if the library still works after the update.\r
- */\r
-\r
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Chart = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){\r
-\r
-},{}],2:[function(require,module,exports){\r
-       /* MIT license */\r
-       var colorNames = require(6);\r
-\r
-       module.exports = {\r
-               getRgba: getRgba,\r
-               getHsla: getHsla,\r
-               getRgb: getRgb,\r
-               getHsl: getHsl,\r
-               getHwb: getHwb,\r
-               getAlpha: getAlpha,\r
-\r
-               hexString: hexString,\r
-               rgbString: rgbString,\r
-               rgbaString: rgbaString,\r
-               percentString: percentString,\r
-               percentaString: percentaString,\r
-               hslString: hslString,\r
-               hslaString: hslaString,\r
-               hwbString: hwbString,\r
-               keyword: keyword\r
-       }\r
-\r
-       function getRgba(string) {\r
-               if (!string) {\r
-                       return;\r
-               }\r
-               var abbr =  /^#([a-fA-F0-9]{3})$/,\r
-                       hex =  /^#([a-fA-F0-9]{6})$/,\r
-                       rgba = /^rgba?\(\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)$/,\r
-                       per = /^rgba?\(\s*([+-]?[\d\.]+)\%\s*,\s*([+-]?[\d\.]+)\%\s*,\s*([+-]?[\d\.]+)\%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)$/,\r
-                       keyword = /(\w+)/;\r
-\r
-               var rgb = [0, 0, 0],\r
-                       a = 1,\r
-                       match = string.match(abbr);\r
-               if (match) {\r
-                       match = match[1];\r
-                       for (var i = 0; i < rgb.length; i++) {\r
-                               rgb[i] = parseInt(match[i] + match[i], 16);\r
-                       }\r
-               }\r
-               else if (match = string.match(hex)) {\r
-                       match = match[1];\r
-                       for (var i = 0; i < rgb.length; i++) {\r
-                               rgb[i] = parseInt(match.slice(i * 2, i * 2 + 2), 16);\r
-                       }\r
-               }\r
-               else if (match = string.match(rgba)) {\r
-                       for (var i = 0; i < rgb.length; i++) {\r
-                               rgb[i] = parseInt(match[i + 1]);\r
-                       }\r
-                       a = parseFloat(match[4]);\r
-               }\r
-               else if (match = string.match(per)) {\r
-                       for (var i = 0; i < rgb.length; i++) {\r
-                               rgb[i] = Math.round(parseFloat(match[i + 1]) * 2.55);\r
-                       }\r
-                       a = parseFloat(match[4]);\r
-               }\r
-               else if (match = string.match(keyword)) {\r
-                       if (match[1] == "transparent") {\r
-                               return [0, 0, 0, 0];\r
-                       }\r
-                       rgb = colorNames[match[1]];\r
-                       if (!rgb) {\r
-                               return;\r
-                       }\r
-               }\r
-\r
-               for (var i = 0; i < rgb.length; i++) {\r
-                       rgb[i] = scale(rgb[i], 0, 255);\r
-               }\r
-               if (!a && a != 0) {\r
-                       a = 1;\r
-               }\r
-               else {\r
-                       a = scale(a, 0, 1);\r
-               }\r
-               rgb[3] = a;\r
-               return rgb;\r
-       }\r
-\r
-       function getHsla(string) {\r
-               if (!string) {\r
-                       return;\r
-               }\r
-               var hsl = /^hsla?\(\s*([+-]?\d+)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)/;\r
-               var match = string.match(hsl);\r
-               if (match) {\r
-                       var alpha = parseFloat(match[4]);\r
-                       var h = scale(parseInt(match[1]), 0, 360),\r
-                               s = scale(parseFloat(match[2]), 0, 100),\r
-                               l = scale(parseFloat(match[3]), 0, 100),\r
-                               a = scale(isNaN(alpha) ? 1 : alpha, 0, 1);\r
-                       return [h, s, l, a];\r
-               }\r
-       }\r
-\r
-       function getHwb(string) {\r
-               if (!string) {\r
-                       return;\r
-               }\r
-               var hwb = /^hwb\(\s*([+-]?\d+)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)/;\r
-               var match = string.match(hwb);\r
-               if (match) {\r
-                       var alpha = parseFloat(match[4]);\r
-                       var h = scale(parseInt(match[1]), 0, 360),\r
-                               w = scale(parseFloat(match[2]), 0, 100),\r
-                               b = scale(parseFloat(match[3]), 0, 100),\r
-                               a = scale(isNaN(alpha) ? 1 : alpha, 0, 1);\r
-                       return [h, w, b, a];\r
-               }\r
-       }\r
-\r
-       function getRgb(string) {\r
-               var rgba = getRgba(string);\r
-               return rgba && rgba.slice(0, 3);\r
-       }\r
-\r
-       function getHsl(string) {\r
-               var hsla = getHsla(string);\r
-               return hsla && hsla.slice(0, 3);\r
-       }\r
-\r
-       function getAlpha(string) {\r
-               var vals = getRgba(string);\r
-               if (vals) {\r
-                       return vals[3];\r
-               }\r
-               else if (vals = getHsla(string)) {\r
-                       return vals[3];\r
-               }\r
-               else if (vals = getHwb(string)) {\r
-                       return vals[3];\r
-               }\r
-       }\r
-\r
-// generators\r
-       function hexString(rgb) {\r
-               return "#" + hexDouble(rgb[0]) + hexDouble(rgb[1])\r
-                       + hexDouble(rgb[2]);\r
-       }\r
-\r
-       function rgbString(rgba, alpha) {\r
-               if (alpha < 1 || (rgba[3] && rgba[3] < 1)) {\r
-                       return rgbaString(rgba, alpha);\r
-               }\r
-               return "rgb(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ")";\r
-       }\r
-\r
-       function rgbaString(rgba, alpha) {\r
-               if (alpha === undefined) {\r
-                       alpha = (rgba[3] !== undefined ? rgba[3] : 1);\r
-               }\r
-               return "rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2]\r
-                       + ", " + alpha + ")";\r
-       }\r
-\r
-       function percentString(rgba, alpha) {\r
-               if (alpha < 1 || (rgba[3] && rgba[3] < 1)) {\r
-                       return percentaString(rgba, alpha);\r
-               }\r
-               var r = Math.round(rgba[0]/255 * 100),\r
-                       g = Math.round(rgba[1]/255 * 100),\r
-                       b = Math.round(rgba[2]/255 * 100);\r
-\r
-               return "rgb(" + r + "%, " + g + "%, " + b + "%)";\r
-       }\r
-\r
-       function percentaString(rgba, alpha) {\r
-               var r = Math.round(rgba[0]/255 * 100),\r
-                       g = Math.round(rgba[1]/255 * 100),\r
-                       b = Math.round(rgba[2]/255 * 100);\r
-               return "rgba(" + r + "%, " + g + "%, " + b + "%, " + (alpha || rgba[3] || 1) + ")";\r
-       }\r
-\r
-       function hslString(hsla, alpha) {\r
-               if (alpha < 1 || (hsla[3] && hsla[3] < 1)) {\r
-                       return hslaString(hsla, alpha);\r
-               }\r
-               return "hsl(" + hsla[0] + ", " + hsla[1] + "%, " + hsla[2] + "%)";\r
-       }\r
-\r
-       function hslaString(hsla, alpha) {\r
-               if (alpha === undefined) {\r
-                       alpha = (hsla[3] !== undefined ? hsla[3] : 1);\r
-               }\r
-               return "hsla(" + hsla[0] + ", " + hsla[1] + "%, " + hsla[2] + "%, "\r
-                       + alpha + ")";\r
-       }\r
-\r
-// hwb is a bit different than rgb(a) & hsl(a) since there is no alpha specific syntax\r
-// (hwb have alpha optional & 1 is default value)\r
-       function hwbString(hwb, alpha) {\r
-               if (alpha === undefined) {\r
-                       alpha = (hwb[3] !== undefined ? hwb[3] : 1);\r
-               }\r
-               return "hwb(" + hwb[0] + ", " + hwb[1] + "%, " + hwb[2] + "%"\r
-                       + (alpha !== undefined && alpha !== 1 ? ", " + alpha : "") + ")";\r
-       }\r
-\r
-       function keyword(rgb) {\r
-               return reverseNames[rgb.slice(0, 3)];\r
-       }\r
-\r
-// helpers\r
-       function scale(num, min, max) {\r
-               return Math.min(Math.max(min, num), max);\r
-       }\r
-\r
-       function hexDouble(num) {\r
-               var str = num.toString(16).toUpperCase();\r
-               return (str.length < 2) ? "0" + str : str;\r
-       }\r
-\r
-\r
-//create a list of reverse color names\r
-       var reverseNames = {};\r
-       for (var name in colorNames) {\r
-               reverseNames[colorNames[name]] = name;\r
-       }\r
-\r
-},{"6":6}],3:[function(require,module,exports){\r
-       /* MIT license */\r
-       var convert = require(5);\r
-       var string = require(2);\r
-\r
-       var Color = function (obj) {\r
-               if (obj instanceof Color) {\r
-                       return obj;\r
-               }\r
-               if (!(this instanceof Color)) {\r
-                       return new Color(obj);\r
-               }\r
-\r
-               this.values = {\r
-                       rgb: [0, 0, 0],\r
-                       hsl: [0, 0, 0],\r
-                       hsv: [0, 0, 0],\r
-                       hwb: [0, 0, 0],\r
-                       cmyk: [0, 0, 0, 0],\r
-                       alpha: 1\r
-               };\r
-\r
-               // parse Color() argument\r
-               var vals;\r
-               if (typeof obj === 'string') {\r
-                       vals = string.getRgba(obj);\r
-                       if (vals) {\r
-                               this.setValues('rgb', vals);\r
-                       } else if (vals = string.getHsla(obj)) {\r
-                               this.setValues('hsl', vals);\r
-                       } else if (vals = string.getHwb(obj)) {\r
-                               this.setValues('hwb', vals);\r
-                       } else {\r
-                               throw new Error('Unable to parse color from string "' + obj + '"');\r
-                       }\r
-               } else if (typeof obj === 'object') {\r
-                       vals = obj;\r
-                       if (vals.r !== undefined || vals.red !== undefined) {\r
-                               this.setValues('rgb', vals);\r
-                       } else if (vals.l !== undefined || vals.lightness !== undefined) {\r
-                               this.setValues('hsl', vals);\r
-                       } else if (vals.v !== undefined || vals.value !== undefined) {\r
-                               this.setValues('hsv', vals);\r
-                       } else if (vals.w !== undefined || vals.whiteness !== undefined) {\r
-                               this.setValues('hwb', vals);\r
-                       } else if (vals.c !== undefined || vals.cyan !== undefined) {\r
-                               this.setValues('cmyk', vals);\r
-                       } else {\r
-                               throw new Error('Unable to parse color from object ' + JSON.stringify(obj));\r
-                       }\r
-               }\r
-       };\r
-\r
-       Color.prototype = {\r
-               rgb: function () {\r
-                       return this.setSpace('rgb', arguments);\r
-               },\r
-               hsl: function () {\r
-                       return this.setSpace('hsl', arguments);\r
-               },\r
-               hsv: function () {\r
-                       return this.setSpace('hsv', arguments);\r
-               },\r
-               hwb: function () {\r
-                       return this.setSpace('hwb', arguments);\r
-               },\r
-               cmyk: function () {\r
-                       return this.setSpace('cmyk', arguments);\r
-               },\r
-\r
-               rgbArray: function () {\r
-                       return this.values.rgb;\r
-               },\r
-               hslArray: function () {\r
-                       return this.values.hsl;\r
-               },\r
-               hsvArray: function () {\r
-                       return this.values.hsv;\r
-               },\r
-               hwbArray: function () {\r
-                       var values = this.values;\r
-                       if (values.alpha !== 1) {\r
-                               return values.hwb.concat([values.alpha]);\r
-                       }\r
-                       return values.hwb;\r
-               },\r
-               cmykArray: function () {\r
-                       return this.values.cmyk;\r
-               },\r
-               rgbaArray: function () {\r
-                       var values = this.values;\r
-                       return values.rgb.concat([values.alpha]);\r
-               },\r
-               hslaArray: function () {\r
-                       var values = this.values;\r
-                       return values.hsl.concat([values.alpha]);\r
-               },\r
-               alpha: function (val) {\r
-                       if (val === undefined) {\r
-                               return this.values.alpha;\r
-                       }\r
-                       this.setValues('alpha', val);\r
-                       return this;\r
-               },\r
-\r
-               red: function (val) {\r
-                       return this.setChannel('rgb', 0, val);\r
-               },\r
-               green: function (val) {\r
-                       return this.setChannel('rgb', 1, val);\r
-               },\r
-               blue: function (val) {\r
-                       return this.setChannel('rgb', 2, val);\r
-               },\r
-               hue: function (val) {\r
-                       if (val) {\r
-                               val %= 360;\r
-                               val = val < 0 ? 360 + val : val;\r
-                       }\r
-                       return this.setChannel('hsl', 0, val);\r
-               },\r
-               saturation: function (val) {\r
-                       return this.setChannel('hsl', 1, val);\r
-               },\r
-               lightness: function (val) {\r
-                       return this.setChannel('hsl', 2, val);\r
-               },\r
-               saturationv: function (val) {\r
-                       return this.setChannel('hsv', 1, val);\r
-               },\r
-               whiteness: function (val) {\r
-                       return this.setChannel('hwb', 1, val);\r
-               },\r
-               blackness: function (val) {\r
-                       return this.setChannel('hwb', 2, val);\r
-               },\r
-               value: function (val) {\r
-                       return this.setChannel('hsv', 2, val);\r
-               },\r
-               cyan: function (val) {\r
-                       return this.setChannel('cmyk', 0, val);\r
-               },\r
-               magenta: function (val) {\r
-                       return this.setChannel('cmyk', 1, val);\r
-               },\r
-               yellow: function (val) {\r
-                       return this.setChannel('cmyk', 2, val);\r
-               },\r
-               black: function (val) {\r
-                       return this.setChannel('cmyk', 3, val);\r
-               },\r
-\r
-               hexString: function () {\r
-                       return string.hexString(this.values.rgb);\r
-               },\r
-               rgbString: function () {\r
-                       return string.rgbString(this.values.rgb, this.values.alpha);\r
-               },\r
-               rgbaString: function () {\r
-                       return string.rgbaString(this.values.rgb, this.values.alpha);\r
-               },\r
-               percentString: function () {\r
-                       return string.percentString(this.values.rgb, this.values.alpha);\r
-               },\r
-               hslString: function () {\r
-                       return string.hslString(this.values.hsl, this.values.alpha);\r
-               },\r
-               hslaString: function () {\r
-                       return string.hslaString(this.values.hsl, this.values.alpha);\r
-               },\r
-               hwbString: function () {\r
-                       return string.hwbString(this.values.hwb, this.values.alpha);\r
-               },\r
-               keyword: function () {\r
-                       return string.keyword(this.values.rgb, this.values.alpha);\r
-               },\r
-\r
-               rgbNumber: function () {\r
-                       var rgb = this.values.rgb;\r
-                       return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];\r
-               },\r
-\r
-               luminosity: function () {\r
-                       // http://www.w3.org/TR/WCAG20/#relativeluminancedef\r
-                       var rgb = this.values.rgb;\r
-                       var lum = [];\r
-                       for (var i = 0; i < rgb.length; i++) {\r
-                               var chan = rgb[i] / 255;\r
-                               lum[i] = (chan <= 0.03928) ? chan / 12.92 : Math.pow(((chan + 0.055) / 1.055), 2.4);\r
-                       }\r
-                       return 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2];\r
-               },\r
-\r
-               contrast: function (color2) {\r
-                       // http://www.w3.org/TR/WCAG20/#contrast-ratiodef\r
-                       var lum1 = this.luminosity();\r
-                       var lum2 = color2.luminosity();\r
-                       if (lum1 > lum2) {\r
-                               return (lum1 + 0.05) / (lum2 + 0.05);\r
-                       }\r
-                       return (lum2 + 0.05) / (lum1 + 0.05);\r
-               },\r
-\r
-               level: function (color2) {\r
-                       var contrastRatio = this.contrast(color2);\r
-                       if (contrastRatio >= 7.1) {\r
-                               return 'AAA';\r
-                       }\r
-\r
-                       return (contrastRatio >= 4.5) ? 'AA' : '';\r
-               },\r
-\r
-               dark: function () {\r
-                       // YIQ equation from http://24ways.org/2010/calculating-color-contrast\r
-                       var rgb = this.values.rgb;\r
-                       var yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;\r
-                       return yiq < 128;\r
-               },\r
-\r
-               light: function () {\r
-                       return !this.dark();\r
-               },\r
-\r
-               negate: function () {\r
-                       var rgb = [];\r
-                       for (var i = 0; i < 3; i++) {\r
-                               rgb[i] = 255 - this.values.rgb[i];\r
-                       }\r
-                       this.setValues('rgb', rgb);\r
-                       return this;\r
-               },\r
-\r
-               lighten: function (ratio) {\r
-                       var hsl = this.values.hsl;\r
-                       hsl[2] += hsl[2] * ratio;\r
-                       this.setValues('hsl', hsl);\r
-                       return this;\r
-               },\r
-\r
-               darken: function (ratio) {\r
-                       var hsl = this.values.hsl;\r
-                       hsl[2] -= hsl[2] * ratio;\r
-                       this.setValues('hsl', hsl);\r
-                       return this;\r
-               },\r
-\r
-               saturate: function (ratio) {\r
-                       var hsl = this.values.hsl;\r
-                       hsl[1] += hsl[1] * ratio;\r
-                       this.setValues('hsl', hsl);\r
-                       return this;\r
-               },\r
-\r
-               desaturate: function (ratio) {\r
-                       var hsl = this.values.hsl;\r
-                       hsl[1] -= hsl[1] * ratio;\r
-                       this.setValues('hsl', hsl);\r
-                       return this;\r
-               },\r
-\r
-               whiten: function (ratio) {\r
-                       var hwb = this.values.hwb;\r
-                       hwb[1] += hwb[1] * ratio;\r
-                       this.setValues('hwb', hwb);\r
-                       return this;\r
-               },\r
-\r
-               blacken: function (ratio) {\r
-                       var hwb = this.values.hwb;\r
-                       hwb[2] += hwb[2] * ratio;\r
-                       this.setValues('hwb', hwb);\r
-                       return this;\r
-               },\r
-\r
-               greyscale: function () {\r
-                       var rgb = this.values.rgb;\r
-                       // http://en.wikipedia.org/wiki/Grayscale#Converting_color_to_grayscale\r
-                       var val = rgb[0] * 0.3 + rgb[1] * 0.59 + rgb[2] * 0.11;\r
-                       this.setValues('rgb', [val, val, val]);\r
-                       return this;\r
-               },\r
-\r
-               clearer: function (ratio) {\r
-                       var alpha = this.values.alpha;\r
-                       this.setValues('alpha', alpha - (alpha * ratio));\r
-                       return this;\r
-               },\r
-\r
-               opaquer: function (ratio) {\r
-                       var alpha = this.values.alpha;\r
-                       this.setValues('alpha', alpha + (alpha * ratio));\r
-                       return this;\r
-               },\r
-\r
-               rotate: function (degrees) {\r
-                       var hsl = this.values.hsl;\r
-                       var hue = (hsl[0] + degrees) % 360;\r
-                       hsl[0] = hue < 0 ? 360 + hue : hue;\r
-                       this.setValues('hsl', hsl);\r
-                       return this;\r
-               },\r
-\r
-               /**\r
-                * Ported from sass implementation in C\r
-                * https://github.com/sass/libsass/blob/0e6b4a2850092356aa3ece07c6b249f0221caced/functions.cpp#L209\r
-                */\r
-               mix: function (mixinColor, weight) {\r
-                       var color1 = this;\r
-                       var color2 = mixinColor;\r
-                       var p = weight === undefined ? 0.5 : weight;\r
-\r
-                       var w = 2 * p - 1;\r
-                       var a = color1.alpha() - color2.alpha();\r
-\r
-                       var w1 = (((w * a === -1) ? w : (w + a) / (1 + w * a)) + 1) / 2.0;\r
-                       var w2 = 1 - w1;\r
-\r
-                       return this\r
-                               .rgb(\r
-                                       w1 * color1.red() + w2 * color2.red(),\r
-                                       w1 * color1.green() + w2 * color2.green(),\r
-                                       w1 * color1.blue() + w2 * color2.blue()\r
-                               )\r
-                               .alpha(color1.alpha() * p + color2.alpha() * (1 - p));\r
-               },\r
-\r
-               toJSON: function () {\r
-                       return this.rgb();\r
-               },\r
-\r
-               clone: function () {\r
-                       // NOTE(SB): using node-clone creates a dependency to Buffer when using browserify,\r
-                       // making the final build way to big to embed in Chart.js. So let's do it manually,\r
-                       // assuming that values to clone are 1 dimension arrays containing only numbers,\r
-                       // except 'alpha' which is a number.\r
-                       var result = new Color();\r
-                       var source = this.values;\r
-                       var target = result.values;\r
-                       var value, type;\r
-\r
-                       for (var prop in source) {\r
-                               if (source.hasOwnProperty(prop)) {\r
-                                       value = source[prop];\r
-                                       type = ({}).toString.call(value);\r
-                                       if (type === '[object Array]') {\r
-                                               target[prop] = value.slice(0);\r
-                                       } else if (type === '[object Number]') {\r
-                                               target[prop] = value;\r
-                                       } else {\r
-                                               console.error('unexpected color value:', value);\r
-                                       }\r
-                               }\r
-                       }\r
-\r
-                       return result;\r
-               }\r
-       };\r
-\r
-       Color.prototype.spaces = {\r
-               rgb: ['red', 'green', 'blue'],\r
-               hsl: ['hue', 'saturation', 'lightness'],\r
-               hsv: ['hue', 'saturation', 'value'],\r
-               hwb: ['hue', 'whiteness', 'blackness'],\r
-               cmyk: ['cyan', 'magenta', 'yellow', 'black']\r
-       };\r
-\r
-       Color.prototype.maxes = {\r
-               rgb: [255, 255, 255],\r
-               hsl: [360, 100, 100],\r
-               hsv: [360, 100, 100],\r
-               hwb: [360, 100, 100],\r
-               cmyk: [100, 100, 100, 100]\r
-       };\r
-\r
-       Color.prototype.getValues = function (space) {\r
-               var values = this.values;\r
-               var vals = {};\r
-\r
-               for (var i = 0; i < space.length; i++) {\r
-                       vals[space.charAt(i)] = values[space][i];\r
-               }\r
-\r
-               if (values.alpha !== 1) {\r
-                       vals.a = values.alpha;\r
-               }\r
-\r
-               // {r: 255, g: 255, b: 255, a: 0.4}\r
-               return vals;\r
-       };\r
-\r
-       Color.prototype.setValues = function (space, vals) {\r
-               var values = this.values;\r
-               var spaces = this.spaces;\r
-               var maxes = this.maxes;\r
-               var alpha = 1;\r
-               var i;\r
-\r
-               if (space === 'alpha') {\r
-                       alpha = vals;\r
-               } else if (vals.length) {\r
-                       // [10, 10, 10]\r
-                      &n