From b88a20ad04c8fad3bee6eeec00a63f0317fbc160 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20Mudr=C3=A1k?= <david@moodle.com>
Date: Tue, 27 Jun 2017 01:02:15 +0200
Subject: [PATCH] MDL-55484 calendar: Fix incorrect date of exported all day
 events

When exporting a calendar into iCalendar format, events with no duration
are interpreted as all day events. To set the correct date for such an
event, we must not use the raw UTC timestamp of the event. Instead we
need to look back and guess what actual date the user had seen in the
date/time picker while creating the event.
---
 calendar/export_execute.php |  7 ++++--
 calendar/lib.php            | 55 +++++++++++++++++++++++++++++++++++++++++++++
 calendar/tests/lib_test.php | 45 +++++++++++++++++++++++++++++++++++++
 3 files changed, 105 insertions(+), 2 deletions(-)

diff --git a/calendar/export_execute.php b/calendar/export_execute.php
index 9952dee978..fc1aa21a98 100644
--- a/calendar/export_execute.php
+++ b/calendar/export_execute.php
@@ -218,8 +218,11 @@ foreach($events as $event) {
         $ev->add_property('dtend', Bennu::timestamp_to_datetime($event->timestart + $event->timeduration));
     } else {
         // When no duration is present, ie an all day event, VALUE should be date instead of time and dtend = dtstart + 1 day.
-        $ev->add_property('dtstart', Bennu::timestamp_to_date($event->timestart), array('value' => 'DATE')); // All day event.
-        $ev->add_property('dtend', Bennu::timestamp_to_date($event->timestart + DAYSECS), array('value' => 'DATE')); // All day event.
+        // But we need to look back at what date the user actually had seen in the date picker element before extracting
+        // it from the UTC timestamp.
+        list($eventdtstart, $eventdtend) = calendar_export_all_day_event_boundaries($event);
+        $ev->add_property('dtstart', $eventdtstart, array('value' => 'DATE'));
+        $ev->add_property('dtend', $eventdtend, array('value' => 'DATE'));
     }
     if ($event->courseid != 0) {
         $coursecontext = context_course::instance($event->courseid);
diff --git a/calendar/lib.php b/calendar/lib.php
index 55ad67fb8f..1e5a580ada 100644
--- a/calendar/lib.php
+++ b/calendar/lib.php
@@ -3330,3 +3330,58 @@ function calendar_get_legacy_events($tstart, $tend, $users, $groups, $courses, $
         return $carry + [$event->get_id() => $mapper->from_event_to_stdclass($event)];
     }, []);
 }
+
+/**
+ * Internal helper function for exporting all day event's start and end dates.
+ *
+ * When exporting a calendar into iCalendar format, events with no duration are
+ * interpreted as all day events. To set the correct date for such an event, we
+ * need to look back and guess what actual date the user had seen in the date/time
+ * picker while creating the event.
+ *
+ * We do that by looking at the event author's timezone and extracting the user
+ * date from the UTC timestamp. As the function is typically called in a loop
+ * over multiple events, a simple ad-hoc cache is used to avoid unnecessary
+ * database hits.
+ *
+ * As any other attempt to interpret existing timestamps based on the current
+ * user settings, this may not be always accurate. This function may return
+ * incorrect values if the event author's timezone has changed, or the server's
+ * default timezone has changed. This is seen as lesser evil when compared to
+ * using the raw UTC timestamp for extracting the date part (unfortunately
+ * Moodle does not keep the original timezone of the timestamp).
+ *
+ * This is supposed to be used internally by calendar export code only.
+ *
+ * @param stdClass $event Event data object with the properties timestart and userid
+ * @return array Event date boundaries formatted as YYYYMMDD
+ */
+function calendar_export_all_day_event_boundaries($event) {
+
+    $cacheusertimezones = cache::make_from_params(cache_store::MODE_REQUEST, 'core_calendar', 'usertimezones');
+
+    if ($event->userid) {
+        // Get the event author's timezone.
+        $timezone = $cacheusertimezones->get($event->userid);
+        if ($timezone === false) {
+            $user = core_user::get_user($event->userid, 'id,timezone');
+            $timezone = $user->timezone;
+            $cacheusertimezones->set($event->userid, $timezone);
+        }
+
+    } else {
+        // We have no other option but using the server default timezone.
+        $timezone = core_date::get_server_timezone();
+    }
+
+    // Get the date that the user would see if editing this event's timestart.
+    $userstart = usergetdate($event->timestart, $timezone);
+    $userend = usergetdate($event->timestart + DAYSECS, $timezone);
+
+    // Format the date for the iCalendar export purposes.
+    $dtstart = sprintf('%04d%02d%02d', $userstart['year'], $userstart['mon'], $userstart['mday']);
+    $dtend = sprintf('%04d%02d%02d', $userend['year'], $userend['mon'], $userend['mday']);
+
+    // Return the tuple of dtstart and dtend.
+    return [$dtstart, $dtend];
+}
diff --git a/calendar/tests/lib_test.php b/calendar/tests/lib_test.php
index 2f627ede6e..61e9daee3b 100644
--- a/calendar/tests/lib_test.php
+++ b/calendar/tests/lib_test.php
@@ -409,4 +409,49 @@ class core_calendar_lib_testcase extends advanced_testcase {
         $events = calendar_get_legacy_events($timestart, $timeend, true, true, true);
         $this->assertCount(3, $events);
     }
+
+    /**
+     * Tests for the behaviour of {@link calendar_export_all_day_event_boundaries()}.
+     */
+    public function test_calendar_export_all_day_event_boundaries() {
+
+        // Prepare the test user.
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user(['timezone' => 'Australia/Perth']);
+
+        // Let the user create an event starting on 27 June 2017 at 6am her time (which is 26 June 22:00 UTC).
+        $event = (object)[
+            'timestart' => make_timestamp(2017, 6, 27, 6, 0, 0, $user->timezone),
+            'userid' => $user->id,
+        ];
+
+        // Get the start and end date to be exported to the iCal.
+        list($start, $end) = calendar_export_all_day_event_boundaries($event);
+
+        // Assert that the all day event starts on 27 June and ends on 28 June.
+        $this->assertEquals(20170627, $start);
+        $this->assertEquals(20170628, $end);
+
+        // Now let the user create an event starting on 27 June 2017 at 4:30pm her time (27 June 08:30 UTC).
+        $event = (object)[
+            'timestart' => make_timestamp(2017, 6, 27, 16, 30, 0, $user->timezone),
+            'userid' => $user->id,
+        ];
+
+        list($start, $end) = calendar_export_all_day_event_boundaries($event);
+        $this->assertEquals(20170627, $start);
+        $this->assertEquals(20170628, $end);
+
+        // Check that exporting of an event with no known author falls back to interpreting
+        // the timestamp according to the server default timezone.
+        $this->setTimezone('Europe/Prague');
+        $event = (object)[
+            'timestart' => make_timestamp(2016, 12, 31, 23, 30, 0, 'Etc/GMT'),
+            'userid' => 0,
+        ];
+
+        list($start, $end) = calendar_export_all_day_event_boundaries($event);
+        $this->assertEquals(20170101, $start);
+        $this->assertEquals(20170102, $end);
+    }
 }
\ No newline at end of file
-- 
2.13.0

