Merge branch 'MDL-59393-master-2' of git://github.com/ryanwyllie/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 23 Aug 2017 01:44:01 +0000 (09:44 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 23 Aug 2017 01:44:01 +0000 (09:44 +0800)
22 files changed:
calendar/amd/build/calendar.min.js
calendar/amd/build/drag_drop.min.js [new file with mode: 0644]
calendar/amd/build/events.min.js
calendar/amd/build/repository.min.js
calendar/amd/src/calendar.js
calendar/amd/src/drag_drop.js [new file with mode: 0644]
calendar/amd/src/events.js
calendar/amd/src/repository.js
calendar/classes/external/calendar_event_exporter.php
calendar/classes/external/event_exporter.php
calendar/classes/external/event_exporter_base.php
calendar/classes/local/api.php
calendar/classes/local/event/mappers/event_mapper.php
calendar/event.php
calendar/externallib.php
calendar/lib.php
calendar/templates/month_detailed.mustache
calendar/tests/externallib_test.php
calendar/tests/local_api_test.php
lib/db/services.php
theme/boost/scss/moodle/core.scss
version.php

index 59256a9..7d3c291 100644 (file)
Binary files a/calendar/amd/build/calendar.min.js and b/calendar/amd/build/calendar.min.js differ
diff --git a/calendar/amd/build/drag_drop.min.js b/calendar/amd/build/drag_drop.min.js
new file mode 100644 (file)
index 0000000..249c6d6
Binary files /dev/null and b/calendar/amd/build/drag_drop.min.js differ
index ca56cb1..762a824 100644 (file)
Binary files a/calendar/amd/build/events.min.js and b/calendar/amd/build/events.min.js differ
index 8cc9d4b..a7c085d 100644 (file)
Binary files a/calendar/amd/build/repository.min.js and b/calendar/amd/build/repository.min.js differ
index b1f37e7..3e49acb 100644 (file)
@@ -59,7 +59,9 @@ define([
     var SELECTORS = {
         ROOT: "[data-region='calendar']",
         EVENT_LINK: "[data-action='view-event']",
-        NEW_EVENT_BUTTON: "[data-action='new-event-button']"
+        NEW_EVENT_BUTTON: "[data-action='new-event-button']",
+        DAY_CONTENT: "[data-region='day-content']",
+        LOADING_ICON: '.loading-icon',
     };
 
     /**
@@ -155,6 +157,60 @@ define([
         }).fail(Notification.exception);
     };
 
+    /**
+     * Handler for the drag and drop move event. Provides a loading indicator
+     * while the request is sent to the server to update the event start date.
+     *
+     * Triggers a eventMoved calendar javascript event if the event was successfully
+     * updated.
+     *
+     * @param {event} e The calendar move event
+     * @param {object} eventElement The jQuery element with the event id
+     * @param {object} originElement The jQuery element for where the event is moving from
+     * @param {object} destinationElement The jQuery element for where the event is moving to
+     */
+    var handleMoveEvent = function(e, eventElement, originElement, destinationElement) {
+        var eventId = eventElement.attr('data-event-id');
+        var originTimestamp = originElement.attr('data-day-timestamp');
+        var destinationTimestamp = destinationElement.attr('data-day-timestamp');
+
+        // If the event has actually changed day.
+        if (originTimestamp != destinationTimestamp) {
+            Templates.render('core/loading', {})
+                .then(function(html, js) {
+                    // First we show some loading icons in each of the days being affected.
+                    originElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');
+                    destinationElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');
+                    Templates.appendNodeContents(originElement, html, js);
+                    Templates.appendNodeContents(destinationElement, html, js);
+                    return;
+                })
+                .then(function() {
+                    // Send a request to the server to make the change.
+                    return CalendarRepository.updateEventStartDay(eventId, destinationTimestamp);
+                })
+                .then(function() {
+                    // If the update was successful then broadcast an event letting the calendar
+                    // know that an event has been moved.
+                    $('body').trigger(CalendarEvents.eventMoved, [eventElement, originElement, destinationElement]);
+                    return;
+                })
+                .always(function() {
+                    // Always remove the loading icons regardless of whether the update
+                    // request was successful or not.
+                    var originLoadingElement = originElement.find(SELECTORS.LOADING_ICON);
+                    var destinationLoadingElement = destinationElement.find(SELECTORS.LOADING_ICON);
+                    originElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');
+                    destinationElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');
+
+                    Templates.replaceNode(originLoadingElement, '', '');
+                    Templates.replaceNode(destinationLoadingElement, '', '');
+                    return;
+                })
+                .fail(Notification.exception);
+        }
+    };
+
     /**
      * Create the event form modal for creating new events and
      * editing existing events.
@@ -204,6 +260,12 @@ define([
             // Action events needs to be edit directly on the course module.
             window.location.assign(url);
         });
+        // Handle the event fired by the drag and drop code.
+        body.on(CalendarEvents.moveEvent, handleMoveEvent);
+        // When an event is successfully moved we should updated the UI.
+        body.on(CalendarEvents.eventMoved, function() {
+            window.location.reload();
+        });
 
         eventFormModalPromise.then(function(modal) {
             // When something within the calendar tells us the user wants
diff --git a/calendar/amd/src/drag_drop.js b/calendar/amd/src/drag_drop.js
new file mode 100644 (file)
index 0000000..f2b17e6
--- /dev/null
@@ -0,0 +1,206 @@
+// 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 javascript module to handle calendar drag and drop. This module
+ * unfortunately requires some state to be maintained because of the
+ * limitations of the HTML5 drag and drop API which means it can't
+ * be used multiple times with the current implementation.
+ *
+ * @module     core_calendar/drag_drop
+ * @class      drag_drop
+ * @package    core_calendar
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+            'jquery',
+            'core_calendar/events'
+        ],
+        function(
+            $,
+            CalendarEvents
+        ) {
+
+    var SELECTORS = {
+        ROOT: "[data-region='calendar']",
+        DRAGGABLE: '[draggable="true"]',
+        DROP_ZONE: '[data-drop-zone="true"]',
+        WEEK: '[data-region="month-view-week"]',
+    };
+    var HOVER_CLASS = 'bg-primary';
+
+    // Unfortunately we are required to maintain some module
+    // level state due to the limitations of the HTML5 drag
+    // and drop API. Specifically the inability to pass data
+    // between the dragstate and dragover events handlers
+    // using the DataTransfer object in the event.
+
+    /** @var int eventId The event id being moved. */
+    var eventId = null;
+    /** @var int duration The number of days the event spans */
+    var duration = null;
+
+    /**
+     * Update the hover state for the event in the calendar to reflect
+     * which days the event will be moved to.
+     *
+     * This funciton supports events spanning multiple days and will
+     * recurse to highlight (or remove highlight) each of the days
+     * that the event will be moved to.
+     *
+     * For example: An event with a duration of 3 days will have
+     * 3 days highlighted when it's dragged elsewhere in the calendar.
+     * The current drag target and the 2 days following it (including
+     * wrapping to the next week if necessary).
+     *
+     * @param {string|object} target The drag target element
+     * @param {bool} hovered If the target is hovered or not
+     * @param {int} count How many days to highlight (default to duration)
+     */
+    var updateHoverState = function(target, hovered, count) {
+        var dropZone = $(target).closest(SELECTORS.DROP_ZONE);
+        if (typeof count === 'undefined') {
+            // This is how many days we need to highlight.
+            count = duration;
+        }
+
+        if (hovered) {
+            dropZone.addClass(HOVER_CLASS);
+        } else {
+            dropZone.removeClass(HOVER_CLASS);
+        }
+
+        count--;
+
+        // If we've still got days to highlight then we should
+        // find the next day.
+        if (count > 0) {
+            var nextDropZone = dropZone.next();
+
+            // If there are no more days in this week then we
+            // need to move down to the next week in the calendar.
+            if (!nextDropZone.length) {
+                var nextWeek = dropZone.closest(SELECTORS.WEEK).next();
+
+                if (nextWeek.length) {
+                    nextDropZone = nextWeek.children(SELECTORS.DROP_ZONE).first();
+                }
+            }
+
+            // If we found another day then let's recursively
+            // update it's hover state.
+            if (nextDropZone.length) {
+                updateHoverState(nextDropZone, hovered, count);
+            }
+        }
+    };
+
+    /**
+     * Set up the module level variables to track which event is being
+     * dragged and how many days it spans.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragstartHandler = function(e) {
+        var eventElement = $(e.target);
+
+        if (!eventElement.is('[data-event-id]')) {
+            eventElement = eventElement.find('[data-event-id]');
+        }
+
+        eventId = eventElement.attr('data-event-id');
+
+        var eventsSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
+        duration = $(eventsSelector).length;
+
+        e.dataTransfer.effectAllowed = "move";
+        e.dataTransfer.dropEffect = "move";
+        // Firefox requires a value to be set here or the drag won't
+        // work and the dragover handler won't fire.
+        e.dataTransfer.setData('text/plain', eventId);
+        e.dropEffect = "move";
+    };
+
+    /**
+     * Update the hover state of the target day element when
+     * the user is dragging an event over it.
+     *
+     * This will add a visual indicator to the calendar UI to
+     * indicate which day(s) the event will be moved to.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragoverHandler = function(e) {
+        e.preventDefault();
+        updateHoverState(e.target, true);
+    };
+
+    /**
+     * Update the hover state of the target day element that was
+     * previously dragged over but has is no longer a drag target.
+     *
+     * This will remove the visual indicator from the calendar UI
+     * that was added by the dragoverHandler.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragleaveHandler = function(e) {
+        e.preventDefault();
+        updateHoverState(e.target, false);
+    };
+
+    /**
+     * Determines the event element, origin day, and destination day
+     * once the user drops the calendar event. These three bits of data
+     * are provided as the payload to the "moveEvent" calendar javascript
+     * event that is fired.
+     *
+     * This will remove the visual indicator from the calendar UI
+     * that was added by the dragoverHandler.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dropHandler = function(e) {
+        e.preventDefault();
+
+        var eventElementSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
+        var eventElement = $(eventElementSelector);
+        var origin = eventElement.closest(SELECTORS.DROP_ZONE);
+        var destination = $(e.target).closest(SELECTORS.DROP_ZONE);
+
+        updateHoverState(e.target, false);
+        $('body').trigger(CalendarEvents.moveEvent, [eventElement, origin, destination]);
+    };
+
+    return {
+        /**
+         * Initialise the event handlers for the drag events.
+         */
+        init: function(root) {
+            root = $(root);
+
+            root.find(SELECTORS.DRAGGABLE).each(function(index, element) {
+                element.addEventListener('dragstart', dragstartHandler, true);
+            });
+
+            root.find(SELECTORS.DROP_ZONE).each(function(index, element) {
+                element.addEventListener('dragover', dragoverHandler, true);
+                element.addEventListener('dragleave', dragleaveHandler, true);
+                element.addEventListener('drop', dropHandler, true);
+            });
+        },
+    };
+});
index 465e337..95e0968 100644 (file)
@@ -29,6 +29,8 @@ define([], function() {
         updated: 'calendar-events:updated',
         editEvent: 'calendar-events:edit_event',
         editActionEvent: 'calendar-events:edit_action_event',
-        monthChanged: 'calendar-events:month_changed'
+        eventMoved: 'calendar-events:event_moved',
+        monthChanged: 'calendar-events:month_changed',
+        moveEvent: 'calendar-events:move_event'
     };
 });
index 5c95483..a8aa489 100644 (file)
@@ -103,9 +103,31 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
         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
+     * date information is extracted, the time of the day is ignored.
+     *
+     * @param {int} eventId The id of the event to update
+     * @param {int} dayTimestamp A timestamp for some time during the target day
+     * @return {promise}
+     */
+    var updateEventStartDay = function(eventId, dayTimestamp) {
+        var request = {
+            methodname: 'core_calendar_update_event_start_day',
+            args: {
+                eventId: eventId,
+                dayTimestamp: dayTimestamp
+            }
+        };
+
+        return Ajax.call([request])[0];
+    };
+
     return {
         getEventById: getEventById,
         deleteEvent: deleteEvent,
+        updateEventStartDay: updateEventStartDay,
         submitCreateUpdateForm: submitCreateUpdateForm,
         getCalendarMonthData: getCalendarMonthData
     };
index 3186390..7ba5805 100644 (file)
@@ -44,16 +44,11 @@ class calendar_event_exporter extends event_exporter_base {
      * @return array
      */
     protected static function define_other_properties() {
-        return [
-            'url' => ['type' => PARAM_URL],
-            'icon' => [
-                'type' => event_icon_exporter::read_properties_definition(),
-            ],
-            'course' => [
-                'type' => course_summary_exporter::read_properties_definition(),
-                'optional' => true,
-            ]
-        ];
+
+        $values = parent::define_other_properties();
+        $values['url'] = ['type' => PARAM_URL];
+
+        return $values;
     }
 
     /**
index 56c3473..917f07e 100644 (file)
@@ -51,7 +51,6 @@ class event_exporter extends event_exporter_base {
 
         $values = parent::define_other_properties();
 
-        $values['canedit'] = ['type' => PARAM_BOOL];
         $values['displayeventsource'] = ['type' => PARAM_BOOL];
         $values['subscription'] = [
             'type' => PARAM_RAW,
@@ -94,8 +93,6 @@ class event_exporter extends event_exporter_base {
         require_once($CFG->dirroot.'/course/lib.php');
 
         $event = $this->event;
-        $legacyevent = container::get_event_mapper()->from_event_to_legacy_event($event);
-
         $context = $this->related['context'];
         $values['isactionevent'] = false;
         $values['iscourseevent'] = false;
@@ -133,14 +130,11 @@ class event_exporter extends event_exporter_base {
             $values['course'] = $coursesummaryexporter->export($output);
         }
 
-        $values['canedit'] = calendar_edit_event_allowed($legacyevent);
-        $values['candelete'] = calendar_delete_event_allowed($legacyevent);
-
         // Handle event subscription.
         $values['subscription'] = null;
         $values['displayeventsource'] = false;
-        if (!empty($legacyevent->subscriptionid)) {
-            $subscription = calendar_get_subscription($legacyevent->subscriptionid);
+        if ($event->get_subscription()) {
+            $subscription = calendar_get_subscription($event->get_subscription()->get('id'));
             if (!empty($subscription) && $CFG->calendar_showicalsource) {
                 $values['displayeventsource'] = true;
                 $subscriptiondata = new \stdClass();
@@ -152,13 +146,9 @@ class event_exporter extends event_exporter_base {
             }
         }
 
-        if ($legacyevent->groupid) {
-            if ($group = calendar_get_group_cached($legacyevent->groupid)) {
-                $values['groupname'] = format_string($group->name, true,
-                        ['context' => \context_course::instance($group->courseid)]);
-            } else {
-                $values['groupname'] = null;
-            }
+        if ($group = $event->get_group()) {
+            $values['groupname'] = format_string($group->get('name'), true,
+                ['context' => \context_course::instance($event->get_course()->get('id'))]);
         }
 
         return $values;
index bc176d3..cc7ef38 100644 (file)
@@ -26,7 +26,10 @@ namespace core_calendar\external;
 
 defined('MOODLE_INTERNAL') || die();
 
+require_once($CFG->dirroot . "/calendar/lib.php");
+
 use \core\external\exporter;
+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;
@@ -153,18 +156,19 @@ class event_exporter_base extends exporter {
      */
     protected static function define_other_properties() {
         return [
-            'url' => ['type' => PARAM_URL],
             'icon' => [
                 'type' => event_icon_exporter::read_properties_definition(),
             ],
-            'action' => [
-                'type' => event_action_exporter::read_properties_definition(),
-                'optional' => true,
-            ],
             'course' => [
                 'type' => course_summary_exporter::read_properties_definition(),
                 'optional' => true,
-            ]
+            ],
+            'canedit' => [
+                'type' => PARAM_BOOL
+            ],
+            'candelete' => [
+                'type' => PARAM_BOOL
+            ],
         ];
     }
 
@@ -177,37 +181,21 @@ class event_exporter_base extends exporter {
     protected function get_other_values(renderer_base $output) {
         $values = [];
         $event = $this->event;
+        $legacyevent = container::get_event_mapper()->from_event_to_legacy_event($event);
         $context = $this->related['context'];
-        if ($moduleproxy = $event->get_course_module()) {
-            $modulename = $moduleproxy->get('modname');
-            $moduleid = $moduleproxy->get('id');
-            $url = new \moodle_url(sprintf('/mod/%s/view.php', $modulename), ['id' => $moduleid]);
-        } else {
-            // TODO MDL-58866 We do not have any way to find urls for events outside of course modules.
-            global $CFG;
-            require_once($CFG->dirroot.'/course/lib.php');
-            $url = \course_get_url($this->related['course'] ?: SITEID);
-        }
         $timesort = $event->get_times()->get_sort_time()->getTimestamp();
         $iconexporter = new event_icon_exporter($event, ['context' => $context]);
 
-        $values['url'] = $url->out(false);
         $values['icon'] = $iconexporter->export($output);
 
-        if ($event instanceof action_event_interface) {
-            $actionrelated = [
-                'context' => $context,
-                'event' => $event
-            ];
-            $actionexporter = new event_action_exporter($event->get_action(), $actionrelated);
-            $values['action'] = $actionexporter->export($output);
-        }
-
         if ($course = $this->related['course']) {
             $coursesummaryexporter = new course_summary_exporter($course, ['context' => $context]);
             $values['course'] = $coursesummaryexporter->export($output);
         }
 
+        $values['canedit'] = calendar_edit_event_allowed($legacyevent, true);
+        $values['candelete'] = calendar_delete_event_allowed($legacyevent);
+
         return $values;
     }
 
index e7bddfd..9f80509 100644 (file)
@@ -26,6 +26,8 @@ namespace core_calendar\local;
 
 defined('MOODLE_INTERNAL') || die();
 
+use core_calendar\local\event\container;
+use core_calendar\local\event\entities\event_interface;
 use core_calendar\local\event\exceptions\limit_invalid_parameter_exception;
 
 /**
@@ -214,4 +216,30 @@ class api {
 
         return $return;
     }
+
+    /**
+     * Change the start day for an event. Only the date will be
+     * modified, the time of day for the event will be left as is.
+     *
+     * @param event_interface $event The existing event to modify
+     * @param DateTimeInterface $startDate The new date to use for the start day
+     * @return event_interface The new event with updated start date
+     */
+    public static function update_event_start_day(
+        event_interface $event,
+        \DateTimeInterface $startDate
+    ) {
+        $mapper = container::get_event_mapper();
+        $legacyevent = $mapper->from_event_to_legacy_event($event);
+        $starttime = $event->get_times()->get_start_time()->setDate(
+            $startDate->format('Y'),
+            $startDate->format('n'),
+            $startDate->format('j')
+        );
+
+        // This function does our capability checks.
+        $legacyevent->update((object) ['timestart' => $starttime->getTimestamp()]);
+
+        return $mapper->from_legacy_event_to_event($legacyevent);
+    }
 }
index 8e544dd..1561e02 100644 (file)
@@ -89,8 +89,16 @@ class event_mapper implements event_mapper_interface {
     public function from_event_to_legacy_event(event_interface $event) {
         $action = ($event instanceof action_event_interface) ? $event->get_action() : null;
         $timeduration = $event->get_times()->get_end_time()->getTimestamp() - $event->get_times()->get_start_time()->getTimestamp();
+        $properties = $this->from_event_to_stdclass($event);
 
-        return new \calendar_event($this->from_event_to_stdclass($event));
+        // Normalise for the legacy event because it wants zero rather than null.
+        $properties->courseid = empty($properties->courseid) ? 0 : $properties->courseid;
+        $properties->groupid = empty($properties->groupid) ? 0 : $properties->groupid;
+        $properties->userid = empty($properties->userid) ? 0 : $properties->userid;
+        $properties->modulename = empty($properties->modulename) ? 0 : $properties->modulename;
+        $properties->instance = empty($properties->instance) ? 0 : $properties->instance;
+
+        return new \calendar_event($properties);
     }
 
     public function from_event_to_stdclass(event_interface $event) {
index 66c6a6f..dad5fd5 100644 (file)
@@ -114,7 +114,7 @@ $formoptions = new stdClass;
 if ($eventid !== 0) {
     $title = get_string('editevent', 'calendar');
     $event = calendar_event::load($eventid);
-    if (!calendar_edit_event_allowed($event)) {
+    if (!calendar_edit_event_allowed($event, true)) {
         print_error('nopermissions');
     }
     $event->action = $action;
index b8ba417..ba62951 100644 (file)
@@ -811,6 +811,10 @@ class core_calendar_external extends external_api {
                 $properties = $legacyevent->properties(true);
             }
 
+            if (!calendar_edit_event_allowed($legacyevent, true)) {
+                print_error('nopermissiontoupdatecalendar');
+            }
+
             $legacyevent->update($properties);
 
             $eventmapper = event_container::get_event_mapper();
@@ -906,4 +910,84 @@ class core_calendar_external extends external_api {
     public static function get_calendar_monthly_view_returns() {
         return \core_calendar\external\month_exporter::get_read_structure();
     }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function update_event_start_day_parameters() {
+        return new external_function_parameters(
+            [
+                'eventId' => new external_value(PARAM_INT, 'Id of event to be updated', VALUE_REQUIRED),
+                'dayTimestamp' => new external_value(PARAM_INT, 'Timestamp for the new start day', VALUE_REQUIRED),
+            ]
+        );
+    }
+
+    /**
+     * Change the start day for the given calendar event to the day that
+     * corresponds with the provided timestamp.
+     *
+     * The timestamp only needs to be anytime within the desired day as only
+     * the date data is extracted from it.
+     *
+     * The event's original time of day is maintained, only the date is shifted.
+     *
+     * @param int $eventId Id of event to be updated
+     * @param int $dayTimestamp Timestamp for the new start day
+     * @return  array
+     */
+    public static function update_event_start_day($eventId, $dayTimestamp) {
+        global $USER, $PAGE;
+
+        // Parameter validation.
+        $params = self::validate_parameters(self::update_event_start_day_parameters(), [
+            'eventId' => $eventId,
+            'dayTimestamp' => $dayTimestamp,
+        ]);
+
+        $vault = event_container::get_event_vault();
+        $mapper = event_container::get_event_mapper();
+        $event = $vault->get_event_by_id($eventId);
+
+        if (!$event) {
+            throw new \moodle_exception('Unable to find event with id ' . $eventId);
+        }
+
+        $legacyevent = $mapper->from_event_to_legacy_event($event);
+
+        if (!calendar_edit_event_allowed($legacyevent, true)) {
+            print_error('nopermissiontoupdatecalendar');
+        }
+
+        self::validate_context($legacyevent->context);
+
+        $newdate = usergetdate($dayTimestamp);
+        $startdatestring = implode('-', [$newdate['year'], $newdate['mon'], $newdate['mday']]);
+        $startdate = new DateTimeImmutable($startdatestring);
+        $event = local_api::update_event_start_day($event, $startdate);
+        $cache = new events_related_objects_cache([$event]);
+        $relatedobjects = [
+            'context' => $cache->get_context($event),
+            'course' => $cache->get_course($event),
+        ];
+        $exporter = new event_exporter($event, $relatedobjects);
+        $renderer = $PAGE->get_renderer('core_calendar');
+
+        return array('event' => $exporter->export($renderer));
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     */
+    public static function update_event_start_day_returns() {
+        return new external_single_structure(
+            array(
+                'event' => event_exporter::get_read_structure()
+            )
+        );
+    }
 }
index bb61015..30cbaa4 100644 (file)
@@ -2435,9 +2435,10 @@ function calendar_set_filters(array $courseeventsfrom, $ignorefilters = false) {
  * Return the capability for editing calendar event.
  *
  * @param calendar_event $event event object
+ * @param bool $manualedit is the event being edited manually by the user
  * @return bool capability to edit event
  */
-function calendar_edit_event_allowed($event) {
+function calendar_edit_event_allowed($event, $manualedit = false) {
     global $USER, $DB;
 
     // Must be logged in.
@@ -2450,6 +2451,12 @@ function calendar_edit_event_allowed($event) {
         return false;
     }
 
+    if ($manualedit && !empty($event->modulename)) {
+        // A user isn't allowed to directly edit an event generated
+        // by a module.
+        return false;
+    }
+
     // You cannot edit URL based calendar subscription events presently.
     if (!empty($event->subscriptionid)) {
         if (!empty($event->subscription->url)) {
index 9809309..19b12ff 100644 (file)
@@ -31,7 +31,7 @@
     {
     }
 }}
-<span class="calendarwrapper" data-courseid="{{courseid}}" data-current-time="{{time}}">
+<span id="month-detailed-{{uniqid}}" class="calendarwrapper" data-courseid="{{courseid}}" data-current-time="{{time}}">
     {{> core_calendar/month_header }}
     {{> core_calendar/month_navigation }}
     <table class="calendarmonth calendartable card-deck m-b-0">
@@ -46,7 +46,7 @@
         </thead>
         <tbody>
     {{#weeks}}
-            <tr>
+            <tr data-region="month-view-week">
                 {{#prepadding}}
                     <td class="dayblank">&nbsp;</td>
                 {{/prepadding}}
@@ -56,7 +56,9 @@
                             }}{{#isweekend}} weekend{{/isweekend}}{{!
                             }}{{#durationevents.0}} duration{{/durationevents.0}}{{!
                             }}{{#durationevents}} duration_{{.}}{{/durationevents}}{{!
-                        }}">
+                        }}"
+                        data-day-timestamp="{{timestamp}}"
+                        data-drop-zone="true">
                         <div class="hidden-sm-down text-xs-center">
                             {{#events.0}}
                                 <a href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
                                 {{mday}}
                             {{/events.0}}
                             {{#events.0}}
-                                <ul>
-                                    {{#events}}
+                                <div data-region="day-content">
+                                    <ul>
+                                        {{#events}}
                                         {{#underway}}
                                             <li class="events-underway">[{{name}}]</li>
                                         {{/underway}}
                                         {{^underway}}
-                                            <li class="calendar_event_{{eventtype}}">
+                                            <li class="calendar_event_{{eventtype}}"
+                                                {{#canedit}}
+                                                    draggable="true"
+                                                    data-drag-type="move"
+                                                {{/canedit}}>
+
                                                 <a data-action="view-event" data-event-id="{{id}}" href="{{url}}">{{name}}</a>
                                             </li>
                                         {{/underway}}
-                                    {{/events}}
-                                </ul>
+                                        {{/events}}
+                                    </ul>
+                                </div>
                             {{/events.0}}
                         </div>
                         <div class="hidden-md-up hidden-desktop">
@@ -84,7 +93,9 @@
                                 <a href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
                             {{/events.0}}
                             {{^events.0}}
-                                {{mday}}
+                                <div data-region="day-content">
+                                    {{mday}}
+                                </div>
                             {{/events.0}}
                         </div>
                     </td>
         </tbody>
     </table>
 </span>
+{{#js}}
+require(['jquery', 'core_calendar/drag_drop'], function($, DragDrop) {
+    var root = $('#month-detailed-{{uniqid}}');
+    DragDrop.init(root);
+});
+{{/js}}
index 6f2ad09..bbf9f37 100644 (file)
@@ -1290,4 +1290,128 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
         $this->expectException('moodle_exception');
         core_calendar_external::delete_calendar_events($params);
     }
+
+    /**
+     * Updating the event start day should change the date value but leave
+     * the time of day unchanged.
+     */
+    public function test_update_event_start_day() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        assign_capability('moodle/calendar:manageownentries', CAP_ALLOW, $roleid, $context, true);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = $this->create_calendar_event(
+            'Test event',
+            $user->id,
+            'user',
+            0,
+            null,
+            [
+                'courseid' => 0,
+                'timestart' => $originalStartTime->getTimestamp()
+            ]
+        );
+
+        $result = core_calendar_external::update_event_start_day($event->id, $newStartDate->getTimestamp());
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::update_event_start_day_returns(),
+            $result
+        );
+
+        $this->assertEquals($expected->getTimestamp(), $result['event']['timestart']);
+    }
+
+    /**
+     * A user should not be able to edit an event that they don't have
+     * capabilities for.
+     */
+    public function test_update_event_start_day_no_permission() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        assign_capability('moodle/calendar:manageownentries', CAP_ALLOW, $roleid, $context, true);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = $this->create_calendar_event(
+            'Test event',
+            $user->id,
+            'user',
+            0,
+            null,
+            [
+                'courseid' => 0,
+                'timestart' => $originalStartTime->getTimestamp()
+            ]
+        );
+
+        assign_capability('moodle/calendar:manageownentries', CAP_PROHIBIT, $roleid, $context, true);
+        $this->expectException('moodle_exception');
+        $result = core_calendar_external::update_event_start_day($event->id, $newStartDate->getTimestamp());
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::update_event_start_day_returns(),
+            $result
+        );
+    }
+
+    /**
+     * A user should not be able to update a module event.
+     */
+    public function test_update_event_start_day_module_event() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $moduleinstance = $generator->get_plugin_generator('mod_assign')
+                                    ->create_instance(['course' => $course->id]);
+        $roleid = $generator->create_role();
+        $context = \context_course::instance($course->id);
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        $generator->enrol_user($user->id, $course->id);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = $this->create_calendar_event(
+            'Test event',
+            $user->id,
+            'user',
+            0,
+            null,
+            [
+                'modulename' => 'assign',
+                'instance' => $moduleinstance->id,
+                'courseid' => $course->id,
+                'timestart' => $originalStartTime->getTimestamp()
+            ]
+        );
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+        $this->expectException('moodle_exception');
+        $result = core_calendar_external::update_event_start_day($event->id, $newStartDate->getTimestamp());
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::update_event_start_day_returns(),
+            $result
+        );
+    }
 }
index 5e4b16a..8e8ad27 100644 (file)
@@ -26,6 +26,8 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once(__DIR__ . '/helpers.php');
 
+use \core_calendar\local\event\container;
+
 /**
  * Class contaning unit tests for the calendar local API.
  *
@@ -858,4 +860,72 @@ class core_calendar_local_api_testcase extends advanced_testcase {
         $events = calendar_get_legacy_events($timestart, $timeend, true, true, true);
         $this->assertCount(3, $events);
     }
+
+    /**
+     * Setting the start date on the calendar event should update the date
+     * of the event but should leave the time of day unchanged.
+     */
+    public function test_update_event_start_day_updates_date() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+        $mapper = container::get_event_mapper();
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        assign_capability('moodle/calendar:manageownentries', CAP_ALLOW, $roleid, $context, true);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = create_event([
+            'name' => 'Test event',
+            'userid' => $user->id,
+            'eventtype' => 'user',
+            'repeats' => 0,
+            'timestart' => $originalStartTime->getTimestamp(),
+        ]);
+        $event = $mapper->from_legacy_event_to_event($event);
+
+        $newEvent = \core_calendar\local\api::update_event_start_day($event, $newStartDate);
+        $actual = $newEvent->get_times()->get_start_time();
+
+        $this->assertEquals($expected->getTimestamp(), $actual->getTimestamp());
+    }
+
+    /**
+     * A user should not be able to update the start date of the event
+     * that they don't have the capabilities to modify.
+     */
+    public function test_update_event_start_day_no_permission() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+        $mapper = container::get_event_mapper();
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = create_event([
+            'name' => 'Test event',
+            'userid' => $user->id,
+            'eventtype' => 'user',
+            'repeats' => 0,
+            'timestart' => $originalStartTime->getTimestamp(),
+        ]);
+        $event = $mapper->from_legacy_event_to_event($event);
+
+        assign_capability('moodle/calendar:manageownentries', CAP_PROHIBIT, $roleid, $context, true);
+        $this->expectException('moodle_exception');
+        $newEvent = \core_calendar\local\api::update_event_start_day($event, $newStartDate);
+    }
 }
index e37d54e..b1f29b6 100644 (file)
@@ -68,6 +68,15 @@ $functions = array(
         'ajax' => true,
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+    'core_calendar_update_event_start_day' => array(
+        'classname' => 'core_calendar_external',
+        'methodname' => 'update_event_start_day',
+        'description' => 'Update the start day (but not time) for an event.',
+        'classpath' => 'calendar/externallib.php',
+        'type' => 'write',
+        'capabilities' => 'moodle/calendar:manageentries, moodle/calendar:manageownentries, moodle/calendar:managegroupentries',
+        'ajax' => true,
+    ),
     'core_calendar_create_calendar_events' => array(
         'classname' => 'core_calendar_external',
         'methodname' => 'create_calendar_events',
index e87fa51..966d885 100644 (file)
@@ -2151,3 +2151,7 @@ $footer-link-color: $bg-inverse-link-color !default;
         top: 0;
     }
 }
+
+[data-drag-type="move"] {
+    cursor: move;
+}
index 88c4bc4..13128ed 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2017082200.01;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2017082300.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.