Merge branch 'MDL-60298' of git://github.com/stronk7/moodle
authorJun Pataleta <jun@moodle.com>
Mon, 9 Oct 2017 08:18:36 +0000 (16:18 +0800)
committerJun Pataleta <jun@moodle.com>
Mon, 9 Oct 2017 08:18:36 +0000 (16:18 +0800)
264 files changed:
admin/index.php
admin/search.php
admin/settings/plugins.php
admin/tool/analytics/classes/output/form/edit_model.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/analyser/base.php
analytics/classes/local/analyser/by_course.php
analytics/classes/local/analyser/sitewide.php
analytics/classes/local/indicator/base.php
analytics/classes/local/time_splitting/base.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/classes/prediction.php
analytics/classes/predictor.php
analytics/tests/fixtures/test_target_course_level_shortname.php [new file with mode: 0644]
analytics/tests/manager_test.php [new file with mode: 0644]
analytics/tests/model_test.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/events.min.js
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/events.js
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_day_exporter.php [new file with mode: 0644]
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/entities/repeat_event_collection.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_day.mustache [new file with mode: 0644]
calendar/templates/calendar_upcoming.mustache [new file with mode: 0644]
calendar/templates/day_detailed.mustache
calendar/templates/day_navigation.mustache
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/month_navigation.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
install/lang/et/install.php
install/lang/mi/langconfig.php
lang/en/admin.php
lang/en/calendar.php
lib/accesslib.php
lib/amd/build/chartjs-lazy.min.js
lib/amd/src/chartjs-lazy.js
lib/behat/classes/partial_named_selector.php
lib/classes/access/get_user_capability_course_helper.php [new file with mode: 0644]
lib/classes/analytics/analyser/student_enrolments.php
lib/classes/external/coursecat_summary_exporter.php [new file with mode: 0644]
lib/classes/output/icon_system_fontawesome.php
lib/classes/task/analytics_cleanup_task.php [new file with mode: 0644]
lib/coursecatlib.php
lib/db/install.xml
lib/db/services.php
lib/db/tasks.php
lib/db/upgrade.php
lib/filestorage/file_system.php
lib/form/form.js
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/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/mlbackend/php/classes/processor.php
lib/mlbackend/python/classes/processor.php
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/accesslib_test.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
lib/webdavlib.php
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/report/overview/report.php
mod/quiz/report/overview/tests/report_test.php
mod/quiz/report/responses/report.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 c8f7129..66270b2 100644 (file)
@@ -45,7 +45,7 @@ class edit_model extends \moodleform {
 
         $mform = $this->_form;
 
-        if ($this->_customdata['model']->get_model_obj()->trained == 1) {
+        if ($this->_customdata['model']->is_trained()) {
             $message = get_string('edittrainedwarning', 'tool_analytics');
             $mform->addElement('html', $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING));
         }
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 ab2f52e..63c75b5 100644 (file)
@@ -112,6 +112,16 @@ abstract class base {
         $this->log = array();
     }
 
+    /**
+     * Returns the list of analysable elements available on the site.
+     *
+     * \core_analytics\local\analyser\by_course and \core_analytics\local\analyser\sitewide are implementing
+     * this method returning site courses (by_course) and the whole system (sitewide) as analysables.
+     *
+     * @return \core_analytics\analysable[]
+     */
+    abstract public function get_analysables();
+
     /**
      * This function returns this analysable list of samples.
      *
@@ -141,7 +151,7 @@ abstract class base {
      *
      * @return string
      */
-    abstract protected function get_samples_origin();
+    abstract public function get_samples_origin();
 
     /**
      * Returns the context of a sample.
@@ -166,15 +176,29 @@ abstract class base {
     /**
      * Main analyser method which processes the site analysables.
      *
-     * \core_analytics\local\analyser\by_course and \core_analytics\local\analyser\sitewide are implementing
-     * this method returning site courses (by_course) and the whole system (sitewide) as analysables.
-     * In most of the cases you should have enough extending from one of these classes so you don't need
-     * to reimplement this method.
-     *
      * @param bool $includetarget
      * @return \stored_file[]
      */
-    abstract public function get_analysable_data($includetarget);
+    public function get_analysable_data($includetarget) {
+
+        $filesbytimesplitting = array();
+
+        $analysables = $this->get_analysables();
+        foreach ($analysables as $analysable) {
+
+            $files = $this->process_analysable($analysable, $includetarget);
+
+            // Later we will need to aggregate data by time splitting method.
+            foreach ($files as $timesplittingid => $file) {
+                $filesbytimesplitting[$timesplittingid][$analysable->get_id()] = $file;
+            }
+        }
+
+        // We join the datasets by time splitting method.
+        $timesplittingfiles = $this->merge_analysable_files($filesbytimesplitting, $includetarget);
+
+        return $timesplittingfiles;
+    }
 
     /**
      * Samples data this analyser provides.
@@ -220,6 +244,36 @@ abstract class base {
         }
     }
 
+    /**
+     * Merges analysable dataset files into 1.
+     *
+     * @param array $filesbytimesplitting
+     * @param bool $includetarget
+     * @return \stored_file[]
+     */
+    protected function merge_analysable_files($filesbytimesplitting, $includetarget) {
+
+        $timesplittingfiles = array();
+        foreach ($filesbytimesplitting as $timesplittingid => $files) {
+
+            if ($this->options['evaluation'] === true) {
+                // Delete the previous copy. Only when evaluating.
+                \core_analytics\dataset_manager::delete_previous_evaluation_file($this->modelid, $timesplittingid);
+            }
+
+            // Merge all course files into one.
+            if ($includetarget) {
+                $filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
+            } else {
+                $filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
+            }
+            $timesplittingfiles[$timesplittingid] = \core_analytics\dataset_manager::merge_datasets($files,
+                $this->modelid, $timesplittingid, $filearea, $this->options['evaluation']);
+        }
+
+        return $timesplittingfiles;
+    }
+
     /**
      * Checks that this analyser satisfies the provided indicator requirements.
      *
index e531bba..8e5b266 100644 (file)
@@ -40,14 +40,14 @@ abstract class by_course extends base {
      *
      * @return \core_analytics\course[]
      */
-    public function get_courses() {
+    public function get_analysables() {
 
         // Default to all system courses.
         if (!empty($this->options['filter'])) {
             $courses = $this->options['filter'];
         } else {
             // Iterate through all potentially valid courses.
-            $courses = get_courses();
+            $courses = get_courses('all', 'c.sortorder ASC');
         }
         unset($courses[SITEID]);
 
@@ -55,7 +55,7 @@ abstract class by_course extends base {
         foreach ($courses as $course) {
             // Skip the frontpage course.
             $analysable = \core_analytics\course::instance($course);
-            $analysables[$analysable->get_id()] = $analysable;
+            $analysables[] = $analysable;
         }
 
         if (empty($analysables)) {
@@ -64,62 +64,4 @@ abstract class by_course extends base {
 
         return $analysables;
     }
-
-    /**
-     * Returns the analysed data
-     *
-     * @param bool $includetarget
-     * @return \stored_file[]
-     */
-    public function get_analysable_data($includetarget) {
-
-        $filesbytimesplitting = array();
-
-        // This class and all children will iterate through a list of courses (\core_analytics\course).
-        $analysables = $this->get_courses('all', 'c.sortorder ASC');
-        foreach ($analysables as $analysableid => $analysable) {
-
-            $files = $this->process_analysable($analysable, $includetarget);
-
-            // Later we will need to aggregate data by time splitting method.
-            foreach ($files as $timesplittingid => $file) {
-                $filesbytimesplitting[$timesplittingid][$analysableid] = $file;
-            }
-        }
-
-        // We join the datasets by time splitting method.
-        $timesplittingfiles = $this->merge_analysable_files($filesbytimesplitting, $includetarget);
-
-        return $timesplittingfiles;
-    }
-
-    /**
-     * Merges analysable dataset files into 1.
-     *
-     * @param array $filesbytimesplitting
-     * @param bool $includetarget
-     * @return \stored_file[]
-     */
-    protected function merge_analysable_files($filesbytimesplitting, $includetarget) {
-
-        $timesplittingfiles = array();
-        foreach ($filesbytimesplitting as $timesplittingid => $files) {
-
-            if ($this->options['evaluation'] === true) {
-                // Delete the previous copy. Only when evaluating.
-                \core_analytics\dataset_manager::delete_previous_evaluation_file($this->modelid, $timesplittingid);
-            }
-
-            // Merge all course files into one.
-            if ($includetarget) {
-                $filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
-            } else {
-                $filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
-            }
-            $timesplittingfiles[$timesplittingid] = \core_analytics\dataset_manager::merge_datasets($files,
-                $this->modelid, $timesplittingid, $filearea, $this->options['evaluation']);
-        }
-
-        return $timesplittingfiles;
-    }
 }
index 9c6fb47..2a21359 100644 (file)
@@ -36,36 +36,12 @@ defined('MOODLE_INTERNAL') || die();
 abstract class sitewide extends base {
 
     /**
-     * Returns the analysable data.
+     * Returns one single analysable element, the site.
      *
-     * @param bool $includetarget
-     * @return \stored_file[] One file for each time splitting method.
+     * @return \core_analytics\analysable[]
      */
-    public function get_analysable_data($includetarget) {
-
-        // Here there is a single analysable and it is the system.
+    public function get_analysables() {
         $analysable = new \core_analytics\site();
-
-        $files = $this->process_analysable($analysable, $includetarget);
-
-        // Copy to range files as there is just one analysable.
-        foreach ($files as $timesplittingid => $file) {
-
-            if ($this->options['evaluation'] === true) {
-                // Delete the previous copy. Only when evaluating.
-                \core_analytics\dataset_manager::delete_previous_evaluation_file($this->modelid, $timesplittingid);
-            }
-
-            // We use merge but it is just a copy.
-            if ($includetarget) {
-                $filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
-            } else {
-                $filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
-            }
-            $files[$timesplittingid] = \core_analytics\dataset_manager::merge_datasets(array($file), $this->modelid,
-                $timesplittingid, $filearea, $this->options['evaluation']);
-        }
-
-        return $files;
+        return array($analysable);
     }
 }
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 fff2c50..3f116f3 100644 (file)
@@ -489,6 +489,54 @@ class manager {
         }
     }
 
+    /**
+     * Cleans up analytics db tables that do not directly depend on analysables that may have been deleted.
+     */
+    public static function cleanup() {
+        global $DB;
+
+        // Clean up stuff that depends on contexts that do not exist anymore.
+        $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
+                  LEFT JOIN {context} ctx ON ap.contextid = ctx.id
+                 WHERE ctx.id IS NULL";
+        $apcontexts = $DB->get_records_sql($sql);
+
+        $sql = "SELECT DISTINCT aic.contextid FROM {analytics_indicator_calc} aic
+                  LEFT JOIN {context} ctx ON aic.contextid = ctx.id
+                 WHERE ctx.id IS NULL";
+        $indcalccontexts = $DB->get_records_sql($sql);
+
+        $contexts = $apcontexts + $indcalccontexts;
+        if ($contexts) {
+            list($sql, $params) = $DB->get_in_or_equal(array_keys($contexts));
+            $DB->execute("DELETE FROM {analytics_prediction_actions} WHERE predictionid IN
+                (SELECT ap.id FROM {analytics_predictions} ap WHERE ap.contextid $sql)", $params);
+
+            $DB->delete_records_select('analytics_predictions', "contextid $sql", $params);
+            $DB->delete_records_select('analytics_indicator_calc', "contextid $sql", $params);
+        }
+
+        // Clean up stuff that depends on analysable ids that do not exist anymore.
+        $models = self::get_all_models();
+        foreach ($models as $model) {
+            $analyser = $model->get_analyser(array('notimesplitting' => true));
+            $analysables = $analyser->get_analysables();
+            if (!$analysables) {
+                continue;
+            }
+
+            $analysableids = array_map(function($analysable) {
+                return $analysable->get_id();
+            }, $analysables);
+
+            list($notinsql, $params) = $DB->get_in_or_equal($analysableids, SQL_PARAMS_NAMED, 'param', false);
+            $params['modelid'] = $model->get_id();
+
+            $DB->delete_records_select('analytics_predict_samples', "modelid = :modelid AND analysableid $notinsql", $params);
+            $DB->delete_records_select('analytics_train_samples', "modelid = :modelid AND analysableid $notinsql", $params);
+        }
+    }
+
     /**
      * Returns the provided element classes in the site.
      *
index dbee08a..4bf9ea1 100644 (file)
@@ -247,15 +247,15 @@ class model {
     /**
      * Returns the model analyser (defined by the model target).
      *
+     * @param array $options Default initialisation with no options.
      * @return \core_analytics\local\analyser\base
      */
-    public function get_analyser() {
+    public function get_analyser($options = array()) {
         if ($this->analyser !== null) {
             return $this->analyser;
         }
 
-        // Default initialisation with no options.
-        $this->init_analyser();
+        $this->init_analyser($options);
 
         return $this->analyser;
     }
@@ -276,26 +276,29 @@ class model {
             throw new \moodle_exception('errornotarget', 'analytics');
         }
 
-        if (!empty($options['evaluation'])) {
-            // The evaluation process will run using all available time splitting methods unless one is specified.
-            if (!empty($options['timesplitting'])) {
-                $timesplitting = \core_analytics\manager::get_time_splitting($options['timesplitting']);
-                $timesplittings = array($timesplitting->get_id() => $timesplitting);
+        $timesplittings = array();
+        if (empty($options['notimesplitting'])) {
+            if (!empty($options['evaluation'])) {
+                // The evaluation process will run using all available time splitting methods unless one is specified.
+                if (!empty($options['timesplitting'])) {
+                    $timesplitting = \core_analytics\manager::get_time_splitting($options['timesplitting']);
+                    $timesplittings = array($timesplitting->get_id() => $timesplitting);
+                } else {
+                    $timesplittings = \core_analytics\manager::get_enabled_time_splitting_methods();
+                }
             } else {
-                $timesplittings = \core_analytics\manager::get_enabled_time_splitting_methods();
-            }
-        } else {
 
-            if (empty($this->model->timesplitting)) {
-                throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
-            }
+                if (empty($this->model->timesplitting)) {
+                    throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
+                }
 
-            // Returned as an array as all actions (evaluation, training and prediction) go through the same process.
-            $timesplittings = array($this->model->timesplitting => $this->get_time_splitting());
-        }
+                // Returned as an array as all actions (evaluation, training and prediction) go through the same process.
+                $timesplittings = array($this->model->timesplitting => $this->get_time_splitting());
+            }
 
-        if (empty($timesplittings)) {
-            throw new \moodle_exception('errornotimesplittings', 'analytics');
+            if (empty($timesplittings)) {
+                throw new \moodle_exception('errornotimesplittings', 'analytics');
+            }
         }
 
         if (!empty($options['evaluation'])) {
@@ -432,17 +435,20 @@ class model {
 
         if ($this->model->timesplitting !== $timesplittingid ||
                 $this->model->indicators !== $indicatorsstr) {
-            // We update the version of the model so different time splittings are not mixed up.
-            $this->model->version = $now;
 
-            // Delete generated predictions.
+            // Delete generated predictions before changing the model version.
             $this->clear_model();
 
-            // Purge all generated files.
-            \core_analytics\dataset_manager::clear_model_files($this->model->id);
+            // It needs to be reset as the version changes.
+            $this->uniqueid = null;
+
+            // We update the version of the model so different time splittings are not mixed up.
+            $this->model->version = $now;
 
             // Reset trained flag.
-            $this->model->trained = 0;
+            if (!$this->is_static()) {
+                $this->model->trained = 0;
+            }
 
         } else if ($this->model->enabled != $enabled) {
             // We purge the cached contexts with insights as some will not be visible anymore.
@@ -456,9 +462,6 @@ class model {
         $this->model->usermodified = $USER->id;
 
         $DB->update_record('analytics_models', $this->model);
-
-        // It needs to be reset (just in case, we may already used it).
-        $this->uniqueid = null;
     }
 
     /**
@@ -472,7 +475,13 @@ class model {
         \core_analytics\manager::check_can_manage_models();
 
         $this->clear_model();
+
+        // Method self::clear_model is already clearing the current model version.
+        $predictor = \core_analytics\manager::get_predictions_processor();
+        $predictor->delete_output_dir($this->get_output_dir(array(), true));
+
         $DB->delete_records('analytics_models', array('id' => $this->model->id));
+        $DB->delete_records('analytics_models_log', array('modelid' => $this->model->id));
     }
 
     /**
@@ -973,13 +982,23 @@ class model {
                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
             }
 
+            // Delete generated predictions before changing the model version.
+            $this->clear_model();
+
+            // It needs to be reset as the version changes.
+            $this->uniqueid = null;
+
             $this->model->timesplitting = $timesplittingid;
             $this->model->version = $now;
+
+            // Reset trained flag.
+            if (!$this->is_static()) {
+                $this->model->trained = 0;
+            }
         }
 
         // Purge pages with insights as this may change things.
-        if ($timesplittingid && $timesplittingid !== $this->model->timesplitting ||
-                $this->model->enabled != 1) {
+        if ($this->model->enabled != 1) {
             $this->purge_insights_cache();
         }
 
@@ -988,9 +1007,6 @@ class model {
 
         // We don't always update timemodified intentionally as we reserve it for target, indicators or timesplitting updates.
         $DB->update_record('analytics_models', $this->model);
-
-        // It needs to be reset (just in case, we may already used it).
-        $this->uniqueid = null;
     }
 
     /**
@@ -1228,9 +1244,10 @@ class model {
      *   models/$model->id/$model->version/execution
      *
      * @param array $subdirs
+     * @param bool $onlymodelid Preference over $subdirs
      * @return string
      */
-    protected function get_output_dir($subdirs = array()) {
+    protected function get_output_dir($subdirs = array(), $onlymodelid = false) {
         global $CFG;
 
         $subdirstr = '';
@@ -1244,8 +1261,12 @@ class model {
             $outputdir = rtrim($CFG->dataroot, '/') . DIRECTORY_SEPARATOR . 'models';
         }
 
-        // Append model id and version + subdirs.
-        $outputdir .= DIRECTORY_SEPARATOR . $this->model->id . DIRECTORY_SEPARATOR . $this->model->version . $subdirstr;
+        // Append model id
+        $outputdir .= DIRECTORY_SEPARATOR . $this->model->id;
+        if (!$onlymodelid) {
+            // Append version + subdirs.
+            $outputdir .= DIRECTORY_SEPARATOR . $this->model->version . $subdirstr;
+        }
 
         make_writable_directory($outputdir);
 
@@ -1410,11 +1431,25 @@ class model {
     private function clear_model() {
         global $DB;
 
+        // Delete current model version stored stuff.
+        $predictor = \core_analytics\manager::get_predictions_processor();
+        $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
+
+        $predictionids = $DB->get_fieldset_select('analytics_predictions', 'id', 'modelid = :modelid',
+            array('modelid' => $this->get_id()));
+        if ($predictionids) {
+            list($sql, $params) = $DB->get_in_or_equal($predictionids);
+            $DB->delete_records_select('analytics_prediction_actions', "predictionid $sql", $params);
+        }
+
         $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
         $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
 
+        // Purge all generated files.
+        \core_analytics\dataset_manager::clear_model_files($this->model->id);
+
         // We don't expect people to clear models regularly and the cost of filling the cache is
         // 1 db read per context.
         $this->purge_insights_cache();
index 549795e..24fa77a 100644 (file)
@@ -68,7 +68,7 @@ class prediction {
     /**
      * Constructor
      *
-     * @param \stdClass $prediction
+     * @param \stdClass|int $prediction
      * @param array $sampledata
      * @return void
      */
index 4b4548b..4dcb491 100644 (file)
@@ -41,4 +41,37 @@ interface predictor {
      * @return bool
      */
     public function is_ready();
+
+    /**
+     * Delete all stored information of the current model id.
+     *
+     * This method is called when there are important changes to a model,
+     * all previous training algorithms using that version of the model
+     * should be deleted.
+     *
+     * In case you want to perform extra security measures before deleting
+     * a directory you can check that $modelversionoutputdir subdirectories
+     * can only be named 'execution', 'evaluation' or 'testing'.
+     *
+     * @param string $uniqueid The site model unique id string
+     * @param string $modelversionoutputdir The output dir of this model version
+     * @return null
+     */
+    public function clear_model($uniqueid, $modelversionoutputdir);
+
+    /**
+     * Delete the output directory.
+     *
+     * This method is called when a model is completely deleted.
+     *
+     * In case you want to perform extra security measures before deleting
+     * a directory you can check that the subdirectories are timestamps
+     * (the model version) and each of this subdirectories' subdirectories
+     * can only be named 'execution', 'evaluation' or 'testing'.
+     *
+     * @param string $modeloutputdir The model directory id (parent of all model versions subdirectories).
+     * @return null
+     */
+    public function delete_output_dir($modeloutputdir);
+
 }
diff --git a/analytics/tests/fixtures/test_target_course_level_shortname.php b/analytics/tests/fixtures/test_target_course_level_shortname.php
new file mode 100644 (file)
index 0000000..1f13d46
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/test_target_shortname.php');
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_target_course_level_shortname extends test_target_shortname {
+
+    /**
+     * get_analyser_class
+     *
+     * @return string
+     */
+    public function get_analyser_class() {
+        return '\core\analytics\analyser\courses';
+    }
+}
diff --git a/analytics/tests/manager_test.php b/analytics/tests/manager_test.php
new file mode 100644 (file)
index 0000000..f4c2a1c
--- /dev/null
@@ -0,0 +1,153 @@
+<?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/>.
+
+/**
+ * Unit tests for the manager.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/fixtures/test_indicator_max.php');
+require_once(__DIR__ . '/fixtures/test_indicator_min.php');
+require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
+require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php');
+
+/**
+ * Unit tests for the manager.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class analytics_manager_testcase extends advanced_testcase {
+
+    /**
+     * test_deleted_context
+     */
+    public function test_deleted_context() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminuser();
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+
+        $target = \core_analytics\manager::get_target('test_target_course_level_shortname');
+        $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
+        foreach ($indicators as $key => $indicator) {
+            $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
+        }
+
+        $model = \core_analytics\model::create($target, $indicators);
+        $modelobj = $model->get_model_obj();
+
+        $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
+        $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
+        $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
+        $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
+
+        $model->enable('\core\analytics\time_splitting\no_splitting');
+
+        $model->train();
+        $model->predict();
+
+        // Generate a prediction action to confirm that it is deleted when there is an important update.
+        $predictions = $DB->get_records('analytics_predictions');
+        $prediction = reset($predictions);
+        $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
+        $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $model->get_target());
+
+        $predictioncontextid = $prediction->get_prediction_data()->contextid;
+
+        $npredictions = $DB->count_records('analytics_predictions', array('contextid' => $predictioncontextid));
+        $npredictionactions = $DB->count_records('analytics_prediction_actions',
+            array('predictionid' => $prediction->get_prediction_data()->id));
+        $nindicatorcalc = $DB->count_records('analytics_indicator_calc', array('contextid' => $predictioncontextid));
+
+        \core_analytics\manager::cleanup();
+
+        // Nothing is incorrectly deleted.
+        $this->assertEquals($npredictions, $DB->count_records('analytics_predictions',
+            array('contextid' => $predictioncontextid)));
+        $this->assertEquals($npredictionactions, $DB->count_records('analytics_prediction_actions',
+            array('predictionid' => $prediction->get_prediction_data()->id)));
+        $this->assertEquals($nindicatorcalc, $DB->count_records('analytics_indicator_calc',
+            array('contextid' => $predictioncontextid)));
+
+        // Now we delete a context, the course predictions and prediction actions should be deleted.
+        $deletedcontext = \context::instance_by_id($predictioncontextid);
+        delete_course($deletedcontext->instanceid, false);
+
+        \core_analytics\manager::cleanup();
+
+        $this->assertEmpty($DB->count_records('analytics_predictions', array('contextid' => $predictioncontextid)));
+        $this->assertEmpty($DB->count_records('analytics_prediction_actions',
+            array('predictionid' => $prediction->get_prediction_data()->id)));
+        $this->assertEmpty($DB->count_records('analytics_indicator_calc', array('contextid' => $predictioncontextid)));
+
+        set_config('enabled_stores', '', 'tool_log');
+        get_log_manager(true);
+    }
+
+    /**
+     * test_deleted_analysable
+     */
+    public function test_deleted_analysable() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminuser();
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+
+        $target = \core_analytics\manager::get_target('test_target_course_level_shortname');
+        $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
+        foreach ($indicators as $key => $indicator) {
+            $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
+        }
+
+        $model = \core_analytics\model::create($target, $indicators);
+        $modelobj = $model->get_model_obj();
+
+        $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
+        $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
+        $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
+        $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
+
+        $model->enable('\core\analytics\time_splitting\no_splitting');
+
+        $model->train();
+        $model->predict();
+
+        $npredictsamples = $DB->count_records('analytics_predict_samples');
+        $ntrainsamples = $DB->count_records('analytics_train_samples');
+
+        // Now we delete an analysable, stored predict and training samples should be deleted.
+        $deletedcontext = \context_course::instance($coursepredict1->id);
+        delete_course($coursepredict1, false);
+
+        \core_analytics\manager::cleanup();
+
+        $this->assertEmpty($DB->count_records('analytics_predict_samples', array('analysableid' => $coursepredict1->id)));
+        $this->assertEmpty($DB->count_records('analytics_train_samples', array('analysableid' => $coursepredict1->id)));
+
+        set_config('enabled_stores', '', 'tool_log');
+        get_log_manager(true);
+    }
+
+}
index b8e1768..fcb7eac 100644 (file)
@@ -77,6 +77,102 @@ class analytics_model_testcase extends advanced_testcase {
         $this->assertInstanceOf('\core_analytics\model', $model);
     }
 
+    /**
+     * test_delete
+     */
+    public function test_delete() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+
+        $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
+        $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
+        $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
+        $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
+
+        $this->model->enable('\core\analytics\time_splitting\no_splitting');
+
+        $this->model->train();
+        $this->model->predict();
+
+        // Fake evaluation results record to check that it is actually deleted.
+        $this->add_fake_log();
+
+        $modeloutputdir = $this->model->get_output_dir(array(), true);
+        $this->assertTrue(is_dir($modeloutputdir));
+
+        // Generate a prediction action to confirm that it is deleted when there is an important update.
+        $predictions = $DB->get_records('analytics_predictions');
+        $prediction = reset($predictions);
+        $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
+        $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
+
+        $this->model->delete();
+        $this->assertEmpty($DB->count_records('analytics_models', array('id' => $this->modelobj->id)));
+        $this->assertEmpty($DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id)));
+        $this->assertEmpty($DB->count_records('analytics_predictions'));
+        $this->assertEmpty($DB->count_records('analytics_prediction_actions'));
+        $this->assertEmpty($DB->count_records('analytics_train_samples'));
+        $this->assertEmpty($DB->count_records('analytics_predict_samples'));
+        $this->assertEmpty($DB->count_records('analytics_used_files'));
+        $this->assertFalse(is_dir($modeloutputdir));
+
+        set_config('enabled_stores', '', 'tool_log');
+        get_log_manager(true);
+    }
+
+    /**
+     * test_clear
+     */
+    public function test_clear() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+
+        $coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
+        $coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
+        $coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
+        $coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
+
+        $this->model->enable('\core\analytics\time_splitting\no_splitting');
+
+        $this->model->train();
+        $this->model->predict();
+
+        // Fake evaluation results record to check that it is actually deleted.
+        $this->add_fake_log();
+
+        // Generate a prediction action to confirm that it is deleted when there is an important update.
+        $predictions = $DB->get_records('analytics_predictions');
+        $prediction = reset($predictions);
+        $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
+        $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
+
+        $modelversionoutputdir = $this->model->get_output_dir();
+        $this->assertTrue(is_dir($modelversionoutputdir));
+
+        // Update to an empty time splitting method to force clear_model execution.
+        $this->model->update(1, false, '');
+        $this->assertFalse(is_dir($modelversionoutputdir));
+
+        // Restore previous time splitting method.
+        $this->model->enable('\core\analytics\time_splitting\no_splitting');
+
+        // Check that most of the stuff got deleted.
+        $this->assertEquals(1, $DB->count_records('analytics_models', array('id' => $this->modelobj->id)));
+        $this->assertEquals(1, $DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id)));
+        $this->assertEmpty($DB->count_records('analytics_predictions'));
+        $this->assertEmpty($DB->count_records('analytics_prediction_actions'));
+        $this->assertEmpty($DB->count_records('analytics_train_samples'));
+        $this->assertEmpty($DB->count_records('analytics_predict_samples'));
+        $this->assertEmpty($DB->count_records('analytics_used_files'));
+
+        set_config('enabled_stores', '', 'tool_log');
+        get_log_manager(true);
+    }
+
     public function test_model_manager() {
         $this->resetAfterTest(true);
 
@@ -99,7 +195,7 @@ class analytics_model_testcase extends advanced_testcase {
 
         $modeldir = $dir . DIRECTORY_SEPARATOR . $this->modelobj->id . DIRECTORY_SEPARATOR . $this->modelobj->version;
         $this->assertEquals($modeldir, $this->model->get_output_dir());
-        $this->assertEquals($modeldir . DIRECTORY_SEPARATOR . 'asd', $this->model->get_output_dir(array('asd')));
+        $this->assertEquals($modeldir . DIRECTORY_SEPARATOR . 'testing', $this->model->get_output_dir(array('testing')));
     }
 
     public function test_unique_id() {
@@ -159,6 +255,25 @@ class analytics_model_testcase extends advanced_testcase {
         $target = \core_analytics\manager::get_target('\core\analytics\target\no_teaching');
         $this->assertTrue(\core_analytics\model::exists($target));
     }
+
+    /**
+     * Generates a model log record.
+     */
+    private function add_fake_log() {
+        global $DB, $USER;
+
+        $log = new stdClass();
+        $log->modelid = $this->modelobj->id;
+        $log->version = $this->modelobj->version;
+        $log->target = $this->modelobj->target;
+        $log->indicators = $this->modelobj->indicators;
+        $log->score = 1;
+        $log->info = json_encode([]);
+        $log->dir = 'not important';
+        $log->timecreated = time();
+        $log->usermodified = $USER->id;
+        $DB->insert_record('analytics_models_log', $log);
+    }
 }
 
 /**
@@ -174,10 +289,11 @@ class testable_model extends \core_analytics\model {
      * get_output_dir
      *
      * @param array $subdirs
+     * @param bool $onlymodelid
      * @return string
      */
-    public function get_output_dir($subdirs = array()) {
-        return parent::get_output_dir($subdirs);
+    public function get_output_dir($subdirs = array(), $onlymodelid = false) {
+        return parent::get_output_dir($subdirs, $onlymodelid);
     }
 
     /**
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 99bf77b..2387dee 100644 (file)
Binary files a/calendar/amd/build/events.min.js and b/calendar/amd/build/events.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..388f46e 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..5d76b1d 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 9134944..a3d0244 100644 (file)
@@ -31,6 +31,7 @@ define([], function() {
         editEvent: 'calendar-events:edit_event',
         editActionEvent: 'calendar-events:edit_action_event',
         eventMoved: 'calendar-events:event_moved',
+        dayChanged: 'calendar-events:day_changed',
         monthChanged: 'calendar-events:month_changed',
         moveEvent: 'calendar-events:move_event',
         filterChanged: 'calendar-events:filter_changed',
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..1115ba1 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,
             }
         };
@@ -110,6 +112,32 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
         return Ajax.call([request])[0];
     };
 
+    /**
+     * Get calendar data for the day view.
+     *
+     * @method getCalendarDayData
+     * @param {Number} year Year
+     * @param {Number} month Month
+     * @param {Number} day Day
+     * @param {Number} courseid The course id.
+     * @param {Number} categoryId The id of the category whose events are shown
+     * @return {promise} Resolved with the day view data.
+     */
+    var getCalendarDayData = function(year, month, day, courseid, categoryId) {
+        var request = {
+            methodname: 'core_calendar_get_calendar_day_view',
+            args: {
+                year: year,
+                month: month,
+                day: day,
+                courseid: courseid,
+                categoryid: categoryId,
+            }
+        };
+
+        return Ajax.call([request])[0];
+    };
+
     /**
      * Change the start day for the given event id. The day timestamp
      * only has to be any time during the target day because only the
@@ -131,11 +159,31 @@ 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,
+        getCalendarDayData: getCalendarDayData,
+        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..8b3d969 100644 (file)
  * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/templates', 'core/notification', 'core_calendar/repository', 'core_calendar/events'],
-    function($, Templates, Notification, CalendarRepository, CalendarEvents) {
+define([
+    'jquery',
+    'core/templates',
+    'core/notification',
+    'core_calendar/repository',
+    'core_calendar/events',
+    'core_calendar/selectors',
+], function(
+    $,
+    Templates,
+    Notification,
+    CalendarRepository,
+    CalendarEvents,
+    CalendarSelectors
+) {
 
         var SELECTORS = {
-            ROOT: "[data-region='calendar']",
             CALENDAR_NAV_LINK: ".calendarwrapper .arrow_link",
-            CALENDAR_MONTH_WRAPPER: ".calendarwrapper",
             LOADING_ICON_CONTAINER: '[data-region="overlay-icon-container"]'
         };
 
@@ -40,11 +51,21 @@ 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(CalendarSelectors.wrapper);
+                var view = wrapper.data('view');
+                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);
 
-                e.preventDefault();
+                if (view === 'month') {
+                    changeMonth(root, link.attr('href'), link.data('year'), link.data('month'), courseId, categoryId);
+                    e.preventDefault();
+                } else if (view === 'day') {
+                    changeDay(root, link.attr('href'), link.data('year'), link.data('month'), link.data('day'),
+                        courseId, categoryId);
+                    e.preventDefault();
+                }
+
             });
         };
 
@@ -55,17 +76,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);
+            target = target || root.find(CalendarSelectors.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 +108,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 +125,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 +135,111 @@ 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 year = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('year');
-            var month = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('month');
+        var reloadCurrentMonth = function(root, courseId, categoryId) {
+            var year = root.find(CalendarSelectors.wrapper).data('year');
+            var month = root.find(CalendarSelectors.wrapper).data('month');
+
+            if (typeof courseId === 'undefined') {
+                courseId = root.find(CalendarSelectors.wrapper).data('courseid');
+            }
+
+            if (typeof categoryId === 'undefined') {
+                categoryId = root.find(CalendarSelectors.wrapper).data('categoryid');
+            }
+
+            return refreshMonthContent(root, year, month, courseId, categoryId);
+        };
+
+
+        /**
+         * Refresh the day content.
+         *
+         * @param {object} root The root element.
+         * @param {Number} year Year
+         * @param {Number} month Month
+         * @param {Number} day Day
+         * @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 refreshDayContent = function(root, year, month, day, courseid, categoryId, target) {
+            startLoading(root);
+
+            target = target || root.find(CalendarSelectors.wrapper);
+
+            M.util.js_pending([root.get('id'), year, month, day, courseid, categoryId].join('-'));
+            var includenavigation = root.data('includenavigation');
+            return CalendarRepository.getCalendarDayData(year, month, day, courseid, categoryId, includenavigation)
+                .then(function(context) {
+                    return Templates.render(root.attr('data-template'), context);
+                })
+                .then(function(html, js) {
+                    return Templates.replaceNode(target, html, js);
+                })
+                .then(function() {
+                    $('body').trigger(CalendarEvents.viewUpdated);
+                    return;
+                })
+                .always(function() {
+                    M.util.js_complete([root.get('id'), year, month, day, courseid, categoryId].join('-'));
+                    return stopLoading(root);
+                })
+                .fail(Notification.exception);
+        };
+
+        /**
+         * Reload the current day view data.
+         *
+         * @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 reloadCurrentDay = function(root, courseId, categoryId) {
+            var wrapper = root.find(CalendarSelectors.wrapper);
+            var year = wrapper.data('year');
+            var month = wrapper.data('month');
+            var day = wrapper.data('day');
 
             if (!courseId) {
-                courseId = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid');
+                courseId = root.find(CalendarSelectors.wrapper).data('courseid');
             }
-            return refreshMonthContent(root, year, month, courseId);
+
+            if (typeof categoryId === 'undefined') {
+                categoryId = root.find(CalendarSelectors.wrapper).data('categoryid');
+            }
+
+            return refreshDayContent(root, year, month, day, courseId, categoryId);
+        };
+
+        /**
+         * Handle changes to the current calendar view.
+         *
+         * @param {object} root The root element.
+         * @param {String} url The calendar url to be shown
+         * @param {Number} year Year
+         * @param {Number} month Month
+         * @param {Number} day Day
+         * @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 changeDay = function(root, url, year, month, day, courseId, categoryId) {
+            return refreshDayContent(root, year, month, day, courseId, categoryId)
+                .then(function() {
+                    if (url.length && url !== '#') {
+                        window.history.pushState({}, '', url);
+                    }
+                    return arguments;
+                })
+                .then(function() {
+                    $('body').trigger(CalendarEvents.dayChanged, [year, month, day, courseId, categoryId]);
+                    return arguments;
+                });
         };
 
         /**
@@ -148,12 +266,50 @@ 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(CalendarSelectors.wrapper);
+
+            if (!courseId) {
+                courseId = root.find(CalendarSelectors.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,
+            reloadCurrentDay: reloadCurrentDay,
+            changeDay: changeDay,
+            refreshDayContent: refreshDayContent,
+            reloadCurrentUpcoming: reloadCurrentUpcoming
         };
     });
diff --git a/calendar/classes/external/calendar_day_exporter.php b/calendar/classes/external/calendar_day_exporter.php
new file mode 100644 (file)
index 0000000..a052874
--- /dev/null
@@ -0,0 +1,281 @@
+<?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 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
+ */
+
+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_day_exporter extends exporter {
+    /**
+     * @var \calendar_information $calendar The calendar to be rendered.
+     */
+    protected $calendar;
+
+    /**
+     * @var moodle_url $url The URL for the day view page.
+     */
+    protected $url;
+
+    /**
+     * Constructor for day 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,
+            ],
+            'categoryid' => [
+                'type' => PARAM_INT,
+                'optional' => true,
+                'default' => 0,
+            ],
+            'neweventtimestamp' => [
+                'type' => PARAM_INT,
+            ],
+            'date' => [
+                'type' => date_exporter::read_properties_definition(),
+            ],
+            'periodname' => [
+                // Note: We must use RAW here because the calendar type returns the formatted month name based on a
+                // calendar format.
+                'type' => PARAM_RAW,
+            ],
+            'previousperiod' => [
+                'type' => date_exporter::read_properties_definition(),
+            ],
+            'previousperiodlink' => [
+                'type' => PARAM_URL,
+            ],
+            'previousperiodname' => [
+                // Note: We must use RAW here because the calendar type returns the formatted month name based on a
+                // calendar format.
+                'type' => PARAM_RAW,
+            ],
+            'nextperiod' => [
+                'type' => date_exporter::read_properties_definition(),
+            ],
+            'nextperiodname' => [
+                // Note: We must use RAW here because the calendar type returns the formatted month name based on a
+                // calendar format.
+                'type' => PARAM_RAW,
+            ],
+            'nextperiodlink' => [
+                'type' => PARAM_URL,
+            ],
+            'larrow' => [
+                // The left arrow defined by the theme.
+                'type' => PARAM_RAW,
+            ],
+            'rarrow' => [
+                // The right arrow defined by the theme.
+                'type' => PARAM_RAW,
+            ],
+        ];
+    }
+
+    /**
+     * 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' => 'day',
+            'time' => $timestamp,
+        ]);
+        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;
+        $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 day 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;
+        }
+
+        if ($this->calendar->categoryid) {
+            $return['categoryid'] = $this->calendar->categoryid;
+        }
+
+        $return['filter_selector'] = $this->get_course_filter_selector($output);
+        $return['courseid'] = $this->calendar->courseid;
+
+        $previousperiod = $this->get_previous_day_data();
+        $nextperiod = $this->get_next_day_data();
+        $date = $this->related['type']->timestamp_to_date_array($this->calendar->time);
+
+        $nextperiodlink = new moodle_url($this->url);
+        $nextperiodlink->param('time', $nextperiod[0]);
+
+        $previousperiodlink = new moodle_url($this->url);
+        $previousperiodlink->param('time', $previousperiod[0]);
+
+        $days = calendar_get_days();
+        $return['date'] = (new date_exporter($date))->export($output);
+        $return['periodname'] = userdate($this->calendar->time, get_string('strftimedaydate'));
+        $return['previousperiod'] = (new date_exporter($previousperiod))->export($output);
+        $return['previousperiodname'] = $days[$previousperiod['wday']]['fullname'];
+        $return['previousperiodlink'] = $previousperiodlink->out(false);
+        $return['nextperiod'] = (new date_exporter($nextperiod))->export($output);
+        $return['nextperiodname'] = $days[$nextperiod['wday']]['fullname'];
+        $return['nextperiodlink'] = $nextperiodlink->out(false);
+        $return['larrow'] = $output->larrow();
+        $return['rarrow'] = $output->rarrow();
+
+        // Need to account for user's timezone.
+        $usernow = usergetdate(time());
+        $today = new \DateTimeImmutable();
+        $neweventtimestamp = $today->setTimestamp($date[0])->setTime(
+            $usernow['hours'],
+            $usernow['minutes'],
+            $usernow['seconds']
+        );
+        $return['neweventtimestamp'] = $neweventtimestamp->getTimestamp();
+
+        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',
+        ];
+    }
+
+    /**
+     * Get the previous day timestamp.
+     *
+     * @return int The previous day timestamp.
+     */
+    protected function get_previous_day_data() {
+        $type = $this->related['type'];
+        $time = $type->get_prev_day($this->calendar->time);
+
+        return $type->timestamp_to_date_array($time);
+    }
+
+    /**
+     * Get the next day timestamp.
+     *
+     * @return int The next day timestamp.
+     */
+    protected function get_next_day_data() {
+        $type = $this->related['type'];
+        $time = $type->get_next_day($this->calendar->time);
+
+        return $type->timestamp_to_date_array($time);
+    }
+}
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 4289cb3..57f5e82 100644 (file)
@@ -122,12 +122,16 @@ class repeat_event_collection implements event_collection_interface {
      * @param int $start Start offset.
      * @return \stdClass[]
      */
-    protected function load_event_records($start = 1) {
+    protected function load_event_records($start = 0) {
         global $DB;
-        while ($records = $DB->get_records(
+        while ($records = $DB->get_records_select(
             'event',
-            ['repeatid' => $this->parentid],
-            '',
+            'id <> :parentid AND repeatid = :repeatid',
+            [
+                'parentid' => $this->parentid,
+                'repeatid' => $this->parentid,
+            ],
+            'id ASC',
             '*',
             $start,
             self::DB_QUERY_LIMIT
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..0c82f8a 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',
@@ -952,6 +972,95 @@ class core_calendar_external extends external_api {
         return \core_calendar\external\month_exporter::get_read_structure();
     }
 
+    /**
+     * Get data for the daily calendar view.
+     *
+     * @param   int     $year The year to be shown
+     * @param   int     $month The month to be shown
+     * @param   int     $day The day to be shown
+     * @param   int     $courseid The course to be included
+     * @return  array
+     */
+    public static function get_calendar_day_view($year, $month, $day, $courseid, $categoryid) {
+        global $CFG, $DB, $USER, $PAGE;
+        require_once($CFG->dirroot."/calendar/lib.php");
+
+        // Parameter validation.
+        $params = self::validate_parameters(self::get_calendar_day_view_parameters(), [
+            'year' => $year,
+            'month' => $month,
+            'day' => $day,
+            'courseid' => $courseid,
+            'categoryid' => $categoryid,
+        ]);
+
+        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, $day);
+        $calendar = new calendar_information(0, 0, 0, $time);
+        $calendar->set_sources($course, $courses, $category);
+
+        list($data, $template) = calendar_get_view($calendar, 'day', $params['includenavigation']);
+
+        return $data;
+    }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function get_calendar_day_view_parameters() {
+        return new external_function_parameters(
+            [
+                'year' => new external_value(PARAM_INT, 'Year to be viewed', VALUE_REQUIRED),
+                'month' => new external_value(PARAM_INT, 'Month to be viewed', VALUE_REQUIRED),
+                'day' => new external_value(PARAM_INT, 'Day 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),
+            ]
+        );
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     */
+    public static function get_calendar_day_view_returns() {
+        return \core_calendar\external\calendar_day_exporter::get_read_structure();
+    }
+
+
     /**
      * Returns description of method parameters.
      *
@@ -1031,4 +1140,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..48754d1 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;
@@ -3100,10 +3231,13 @@ function calendar_get_view(\calendar_information $calendar, $view, $includenavig
         $month->set_includenavigation($includenavigation);
         $data = $month->export($renderer);
     } else if ($view == "day") {
-        $daydata = $type->timestamp_to_date_array($tstart);
-        $day = new \core_calendar\external\day_exporter($calendar, $daydata, $related);
+        $day = new \core_calendar\external\calendar_day_exporter($calendar, $related);
         $data = $day->export($renderer);
-        $template = 'core_calendar/day_detailed';
+        $template = 'core_calendar/calendar_day';
+    } 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 +3257,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 +3290,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 +3400,7 @@ function calendar_get_footer_options($calendar) {
 function calendar_get_filter_types() {
     $types = [
         'site',
+        'category',
         'course',
         'group',
         'user',
@@ -3274,3 +3413,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_day.mustache b/calendar/templates/calendar_day.mustache
new file mode 100644 (file)
index 0000000..7d75b57
--- /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_day
+
+    Calendar day view.
+
+    The purpose of this template is to render the calendar day view.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+    }
+}}
+<div id="calendar-day-{{uniqid}}" data-template="core_calendar/day_detailed">
+    {{> core_calendar/day_detailed}}
+</div>
+{{#js}}
+require(['jquery', 'core_calendar/calendar_view'], function($, CalendarView) {
+    CalendarView.init($("#calendar-day-{{uniqid}}"), 'day');
+});
+{{/js}}
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 cddfae3..74d48bb 100644 (file)
     {
     }
 }}
-<div class="header">
-    {{{filter_selector}}}
-    {{{new_event_button}}}
+<div{{!
+    }} class="calendarwrapper"{{!
+    }} data-context-id="{{defaulteventcontext}}"{{!
+    }}{{#courseid}} data-courseid="{{courseid}}"{{/courseid}}{{!
+    }}{{#categoryid}} data-categoryid="{{categoryid}}"{{/categoryid}}{{!
+    }} data-view="day"{{!
+    }} data-year="{{date.year}}"{{!
+    }} data-month="{{date.mon}}"{{!
+    }} data-day="{{date.mday}}"{{!
+    }} data-region="day"{{!
+    }} data-new-event-timestamp="{{neweventtimestamp}}"{{!
+}}>
+    {{> core_calendar/header}}
+    {{> core_calendar/day_navigation }}
+    {{> core/overlay_loading}}
+    {{> core_calendar/event_list }}
 </div>
-{{> core_calendar/day_navigation }}
-{{> core_calendar/event_list }}
\ No newline at end of file
index fce9dc2..d2c7eca 100644 (file)
     {
     }
 }}
-{{#navigation}}
-<div class="controls" data-view="{{view}}">
-    {{{navigation}}}
+<div id="day-navigation-{{uniqid}}" class="controls clearfix">
+    <div class="calendar-controls">
+        <a{{!
+            }} href="{{previousperiodlink}}"{{!
+            }} class="arrow_link previous"{{!
+            }} title="{{#str}}dayprev, calendar{{/str}}"{{!
+            }} data-year="{{previousperiod.year}}"{{!
+            }} data-month="{{previousperiod.mon}}"{{!
+            }} data-day="{{previousperiod.mday}}"{{!
+        }}>
+            <span class="arrow">{{{larrow}}}</span>
+            &nbsp;
+            <span class="arrow_text">{{previousperiodname}}</span>
+        </a>
+        <span class="hide"> | </span>
+        <h2 class="current">{{periodname}}</h2>
+        <span class="hide"> | </span>
+        <a{{!
+            }} href="{{nextperiodlink}}"{{!
+            }} class="arrow_link next"{{!
+            }} title="{{#str}}daynext, calendar{{/str}}"{{!
+            }} data-year="{{nextperiod.year}}"{{!
+            }} data-month="{{nextperiod.mon}}"{{!
+            }} data-day="{{nextperiod.mday}}"{{!
+        }}>
+            <span class="arrow_text">{{nextperiodname}}</span>
+            &nbsp;
+            <span class="arrow">{{{rarrow}}}</span>
+        </a>
+    </div>
 </div>
-{{/navigation}}
-
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..942e459 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}}"{{!
+    }} data-view="month"{{!
     }}>
     {{> 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}}"{{!
     }}>
index 68180c2..66eccc0 100644 (file)
@@ -31,7 +31,7 @@
     {
     }
 }}
-<div id="month-navigation-{{uniqid}}" class="controls" data-view="{{view}}">
+<div id="month-navigation-{{uniqid}}" class="controls">
     <div class="calendar-controls">
         <a{{!
             }} href="{{previousperiodlink}}"{{!
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"