Browse Source

Issue #436 Fix: Calendar timezone rendering (#437)

* Convert calendar occurrences to Django TIME_ZONE
* Upgraded fullcalendar.js to 5.9
* Made some corresponding changes to calendar display settings based on fullcalendar 5.9 changes.
* Force fullcalendar to display events in TIME_ZONE and add a message showing such below the calendar.

Co-authored-by: Vince Salvino <salvino@coderedcorp.com>
Roxanna Coldiron 3 years ago
parent
commit
263cd67e8d

+ 73 - 25
coderedcms/models/page_models.py

@@ -6,8 +6,8 @@ import json
 import logging
 import os
 import warnings
-from datetime import datetime
-from typing import Optional, TYPE_CHECKING
+from datetime import date, datetime
+from typing import Dict, List, Optional, TYPE_CHECKING, Union
 
 import geocoder
 from django import forms
@@ -912,21 +912,6 @@ class CoderedEventPage(CoderedWebPage, BaseEvent):
         return events
 
 
-class DefaultCalendarViewChoices():
-    MONTH = 'month'
-    AGENDA_WEEK = 'agendaWeek'
-    AGENDA_DAY = 'agendaDay'
-    LIST_MONTH = 'listMonth'
-
-    CHOICES = (
-        ('', _('No calendar')),
-        (MONTH, _('Monthly Calendar')),
-        (AGENDA_WEEK, _('Weekly Calendar')),
-        (AGENDA_DAY, _('Daily Calendar')),
-        (LIST_MONTH, _('Calendar List View')),
-    )
-
-
 class CoderedEventIndexPage(CoderedWebPage):
     """
     Shows a list of event sub-pages.
@@ -935,6 +920,18 @@ class CoderedEventIndexPage(CoderedWebPage):
         verbose_name = _('CodeRed Event Index Page')
         abstract = True
 
+    class CalendarViews(models.TextChoices):
+        NONE = '', _('No calendar')
+        MONTH = 'month', _('Month')
+        AGENDA_WEEK = 'agendaWeek', _('Week')
+        AGENDA_DAY = 'agendaDay', _('Day')
+        LIST_MONTH = 'listMonth', _('List of events')
+
+    class EventStyles(models.TextChoices):
+        DEFAULT = '', _('Default')
+        BLOCK = 'block', _('Solid rectangles')
+        DOT = 'list-item', _('Dots with labels')
+
     template = 'coderedcms/pages/event_index_page.html'
 
     index_show_subpages_default = True
@@ -946,16 +943,55 @@ class CoderedEventIndexPage(CoderedWebPage):
 
     default_calendar_view = models.CharField(
         blank=True,
-        choices=DefaultCalendarViewChoices.CHOICES,
+        choices=CalendarViews.choices,
+        default=CalendarViews.MONTH,
         max_length=255,
         verbose_name=_('Calendar Style'),
         help_text=_('The default look of the calendar on this page.')
     )
+    event_style = models.CharField(
+        blank=True,
+        choices=EventStyles.choices,
+        default=EventStyles.DEFAULT,
+        max_length=255,
+        verbose_name=_('Event Style'),
+        help_text=_('How events look on the calendar.')
+    )
 
     layout_panels = CoderedWebPage.layout_panels + [
-        FieldPanel('default_calendar_view'),
+        MultiFieldPanel(
+            [
+                FieldPanel('default_calendar_view'),
+                FieldPanel('event_style'),
+            ],
+            heading=_('Calendar Style'),
+        )
     ]
 
+    @property
+    def fullcalendar_view(self) -> str:
+        """
+        Translate calendar views to fullcalendar.js identifiers.
+        """
+        return {
+            self.CalendarViews.NONE: '',
+            self.CalendarViews.MONTH: 'dayGridMonth',
+            self.CalendarViews.AGENDA_WEEK: 'timeGridWeek',
+            self.CalendarViews.AGENDA_DAY: 'timeGridDay',
+            self.CalendarViews.LIST_MONTH: 'listMonth',
+        }[self.default_calendar_view]
+
+    @property
+    def fullcalendar_event_display(self) -> str:
+        """
+        Translate event display styles to fullcalendar.js identifiers.
+        """
+        return {
+            self.EventStyles.DEFAULT: 'auto',
+            self.EventStyles.BLOCK: 'block',
+            self.EventStyles.DOT: 'list-item',
+        }[self.event_style]
+
     def get_index_children(self):
         if self.index_query_pagemodel and self.index_order_by == 'next_occurrence':
             querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
@@ -965,12 +1001,20 @@ class CoderedEventIndexPage(CoderedWebPage):
             for event in qs.all():
                 if event.next_occurrence():
                     upcoming.append(event)
-            # sort the events by next_occurrence
-            return sorted(upcoming, key=lambda e: e.next_occurrence())
+            # Sort the events by next_occurrence start date.
+            return sorted(upcoming, key=lambda e: e.next_occurrence()[0])
 
         return super().get_index_children()
 
-    def get_calendar_events(self, start, end):
+    def get_calendar_events(
+        self,
+        start: Union[datetime, date],
+        end: Union[datetime, date]
+    ) -> List[Dict[str, str]]:
+        """
+        Returns a list of event occurrences as dictionaries with times
+        converted to Django TIME_ZONE settings.
+        """
         # start with all child events, regardless of get_index_children rules.
         querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
         qs = querymodel.objects.child_of(self).live()
@@ -978,16 +1022,20 @@ class CoderedEventIndexPage(CoderedWebPage):
         for event in qs:
             occurrences = event.query_occurrences(limit=None, from_date=start, to_date=end)
             for occurrence in occurrences:
+                local_start = timezone.localtime(value=occurrence[0])
+                local_end = None
+                if occurrence[1]:
+                    local_end = timezone.localtime(value=occurrence[1])
                 event_data = {
                     'title': event.title,
-                    'start': occurrence[0].strftime('%Y-%m-%dT%H:%M:%S'),
-                    'end': occurrence[1].strftime('%Y-%m-%dT%H:%M:%S') if occurrence[1] else "",
+                    'start': local_start.strftime('%Y-%m-%dT%H:%M:%S%z'),
+                    'end': local_end.strftime('%Y-%m-%dT%H:%M:%S%z') if local_end else "",
                     'description': "",
                 }
                 if event.url:
                     event_data['url'] = event.url
                 if event.calendar_color:
-                    event_data['backgroundColor'] = event.calendar_color
+                    event_data['color'] = event.calendar_color
                 event_instances.append(event_data)
         return event_instances
 

+ 37 - 43
coderedcms/static/coderedcms/js/codered-front.js

@@ -1,6 +1,6 @@
 /*
 CodeRed CMS (https://www.coderedcorp.com/cms/)
-Copyright 2018-2019 CodeRed LLC
+Copyright 2018-2021 CodeRed LLC
 License: https://github.com/coderedcorp/coderedcms/blob/dev/LICENSE
 @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
 */
@@ -29,19 +29,10 @@ libs = {
         integrity: "sha256-mvFcf2wocDC8U1GJdTVSmMHBn/dBLNeJjYRvBhM6gc8=",
         head: '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pickadate.js/3.6.3/compressed/themes/default.time.css" integrity="sha256-dtpQarv++ugnrcY7o6Gr3m7fIJFJDSx8v76jjTqEeKE=" crossorigin="anonymous" />'
     },
-    jquery_ui: {
-        url: "https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.js",
-        integrity: "sha256-T0Vest3yCU7pafRw9r+settMBX6JkKN06dqBnpQ8d30=",
-    },
-    jquery_qtip: {
-        url: "https://cdnjs.cloudflare.com/ajax/libs/qtip2/3.0.3/basic/jquery.qtip.min.js",
-        integrity: "sha256-219NoyU6iEtgMGleoW1ttROUEs/sux5DplKJJQefDwE=",
-    },
     fullcalendar: {
-        url: "https://cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.10.0/fullcalendar.min.js",
-        integrity: "sha256-4+rW6N5lf9nslJC6ut/ob7fCY2Y+VZj2Pw/2KdmQjR0=",
-        head: '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.10.0/fullcalendar.min.css" integrity="sha256-9VgA72/TnFndEp685+regIGSD6voLveO2iDuWhqTY3g=" crossorigin="anonymous" />' +
-              '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.10.0/fullcalendar.print.min.css" media="print" integrity="sha256-JYJWCNB1pXBwUngem7hITwB6SdmCGkhewhKS8NL1A8A=" crossorigin="anonymous" />'
+        url: "https://cdn.jsdelivr.net/npm/fullcalendar@5.9.0/main.min.js",
+        integrity: "sha256-8nl2O4lMNahIAmUnxZprMxJIBiPv+SzhMuYwEuinVM0=",
+        head: '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@5.9.0/main.min.css" integrity="sha256-FjyLCG3re1j4KofUTQQXmaWJw13Jdb7LQvXlkFxTDJI=" crossorigin="anonymous">'
     },
     coderedmaps: {
         url: "/static/coderedcms/js/codered-maps.js",
@@ -156,37 +147,40 @@ $(document).ready(function()
 
     /*** Calendar **/
     if ( $("[data-block='calendar']").length > 0){
-        load_script(libs.jquery_ui, function(){
-            load_script(libs.jquery_qtip, function(){
-                load_script(libs.moment, function(){
-                    load_script(libs.fullcalendar, function(){
-                        var pageId = "";
-                        var defaultDate = "";
-                        var defaultView = "";
-                        $('[data-block="calendar"]').each(function(index, obj){
-                            pageId = $(obj).data('page-id');
-                            defaultDate = $(obj).data('default-date');
-                            defaultView = $(obj).data('default-view');
-                            $(obj).fullCalendar({
-                                header: {
-                                    left: 'prev,next,today',
-                                    center: 'title',
-                                    right: 'month,agendaWeek,agendaDay,listMonth'
-                                },
-                                defaultDate: defaultDate,
-                                defaultView: defaultView,
-                                fixedWeekCount: false,
-                                events: {
-                                    url: '/ajax/calendar/events/',
-                                    type: 'GET',
-                                    data: {
-                                        'pid': pageId
-                                    }
-                                }
-                            });
-                        });
-                    });
+        load_script(libs.fullcalendar, function(){
+            var calendars = document.querySelectorAll("[data-block='calendar']");
+            calendars.forEach(function(el){
+                var pageId = el.dataset.pageId; // data-page-id
+                var defaultDate = el.dataset.defaultDate; // data-default-date
+                var defaultView = el.dataset.defaultView; // data-default-view
+                var eventDisplay = el.dataset.eventDisplay; // data-event-display
+                var timezone = el.dataset.timezone; // data-timezone
+                var calendar = new FullCalendar.Calendar(el, {
+                    headerToolbar: {
+                        left: 'prev,next today',
+                        center: 'title',
+                        right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
+                    },
+                    themeSystem: 'bootstrap',
+                    bootstrapFontAwesome: false,
+                    buttonText: {
+                        'prev': '< prev',
+                        'next': 'next >'
+                    },
+                    initialDate: defaultDate,
+                    initialView: defaultView,
+                    fixedWeekCount: false,
+                    timeZone: timezone,
+                    eventDisplay: eventDisplay,
+                    eventSources: {
+                        url: '/ajax/calendar/events/',
+                        method: 'GET',
+                        extraParams: {
+                            'pid': pageId
+                        }
+                    }
                 });
+                calendar.render();
             });
         });
     }

+ 13 - 0
coderedcms/templates/coderedcms/includes/ical/calendar.html

@@ -0,0 +1,13 @@
+{% load coderedcms_tags %}
+<div
+    data-block="calendar"
+    data-default-date='{% now "Y-m-d" %}'
+    data-default-view='{{ page.fullcalendar_view }}'
+    data-event-display='{{ page.fullcalendar_event_display }}'
+    data-page-id="{{ page.id }}"
+    data-timezone="{{ 'TIME_ZONE'|django_settings }}">
+    <noscript>JavaScript is required to view the Calendar.</noscript>
+</div>
+<div class="text-right text-muted">
+    <i>Listed in {{ 'TIME_ZONE'|django_settings }} time.</i>
+</div>

+ 6 - 4
coderedcms/templates/coderedcms/includes/ical/calendar_ical_button.html

@@ -1,5 +1,7 @@
-<form data-attribute="calendar-ical-form" class="calendar-ical-form" action="{% url 'event_generate_ical_for_calendar' %}" method="POST">
-        <input name="page_id" type="number" hidden value="{{ page.id }}" />
-        {% csrf_token %}
-    <button class="btn btn-primary" title="Download calendar in .ical format" type="submit">{% block button_text %}Download Calendar{% endblock %}</button>
+<form class="calendar-ical-form" action="{% url 'event_generate_ical_for_calendar' %}" method="POST">
+    <input name="page_id" type="number" hidden value="{{ page.id }}" />
+    {% csrf_token %}
+    <button class="btn btn-primary" title="Download calendar in .ics format" type="submit">
+        {% block button_text %}Download Calendar (ical){% endblock %}
+    </button>
 </form>

+ 1 - 3
coderedcms/templates/coderedcms/pages/event_index_page.html

@@ -7,9 +7,7 @@
     <div class="container">
         <div class="row">
             <div class="col pt-5 pb-5">
-                <div data-block="calendar" data-default-date='{% now "Y-m-d" %}' data-default-view='{{self.default_calendar_view}}' data-page-id="{{ page.id }}">
-                    <noscript>Javascript is required to view the Calendar.</noscript>
-                </div>
+                {% include 'coderedcms/includes/ical/calendar.html' %}
             </div>
         </div>
         <div class="row">

+ 9 - 2
coderedcms/tests/test_urls.py

@@ -6,6 +6,7 @@ from ast import literal_eval
 from django.urls import reverse
 from django.test import Client
 from django.test.utils import override_settings
+from django.utils import timezone
 
 from wagtail.core.models import Site, Page
 from wagtail.images.tests.utils import Image, get_test_image_file
@@ -246,13 +247,19 @@ class TestEventURLs(unittest.TestCase):
         # Get datetimes from response and compare them to datetimes on page
         start = literal_eval(response._container[0].decode()[1:-1])['start']
         end = literal_eval(response._container[0].decode()[1:-1])['end']
+        event_local_start = timezone.localtime(
+            EventOccurrence.objects.get(event=event_page).start
+        )
+        event_local_end = timezone.localtime(
+            EventOccurrence.objects.get(event=event_page).end
+        )
         self.assertEqual(
             start,
-            EventOccurrence.objects.get(event=event_page).start.strftime("%Y-%m-%dT%H:%M:%S")
+            event_local_start.strftime("%Y-%m-%dT%H:%M:%S%z")
         )
         self.assertEqual(
             end,
-            EventOccurrence.objects.get(event=event_page).end.strftime("%Y-%m-%dT%H:%M:%S")
+            event_local_end.strftime("%Y-%m-%dT%H:%M:%S%z")
         )
 
 

+ 23 - 0
coderedcms/tests/testapp/migrations/0007_auto_20210910_1614.py

@@ -0,0 +1,23 @@
+# Generated by Django 3.2.7 on 2021-09-10 20:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('testapp', '0006_indextestpage'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='eventindexpage',
+            name='event_style',
+            field=models.CharField(blank=True, choices=[('', 'Default'), ('block', 'Solid rectangles'), ('list-item', 'Dots with labels')], default='', help_text='How events look on the calendar.', max_length=255, verbose_name='Event Style'),
+        ),
+        migrations.AlterField(
+            model_name='eventindexpage',
+            name='default_calendar_view',
+            field=models.CharField(blank=True, choices=[('', 'No calendar'), ('month', 'Month'), ('agendaWeek', 'Week'), ('agendaDay', 'Day'), ('listMonth', 'List of events')], default='month', help_text='The default look of the calendar on this page.', max_length=255, verbose_name='Calendar Style'),
+        ),
+    ]

+ 24 - 12
coderedcms/views.py

@@ -7,6 +7,7 @@ from django.contrib.auth.decorators import login_required
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import Paginator, InvalidPage, EmptyPage, PageNotAnInteger
 from django.shortcuts import redirect, render
+from django.utils import timezone
 from django.utils.translation import ungettext, gettext_lazy as _
 from icalendar import Calendar
 from wagtail.admin import messages
@@ -185,7 +186,6 @@ def event_generate_ical_for_calendar(request):
     if request.method == "POST":
         try:
             page = CoderedPage.objects.get(id=request.POST.get('page_id')).specific
-            print(page)
         except ValueError:
             raise Http404
 
@@ -201,17 +201,29 @@ def event_generate_ical_for_calendar(request):
 
 
 def event_get_calendar_events(request):
-    if request.is_ajax():
-        try:
-            page = CoderedPage.objects.get(id=request.GET.get('pid')).specific
-        except ValueError:
-            raise Http404
-        start_str = request.GET.get('start')
-        start = datetime.strptime(start_str[:10], "%Y-%m-%d") if start_str else None
-        end_str = request.GET.get('end')
-        end = datetime.strptime(end_str[:10], "%Y-%m-%d") if end_str else None
-        return JsonResponse(page.get_calendar_events(start=start, end=end), safe=False)
-    raise Http404()
+    """
+    JSON list of events compatible with fullcalendar.js
+    """
+    try:
+        page = CoderedPage.objects.get(id=request.GET.get('pid')).specific
+    except ValueError:
+        raise Http404
+    start = None
+    end = None
+    start_str = request.GET.get('start', None)
+    end_str = request.GET.get('end', None)
+    if start_str:
+        start = timezone.make_aware(
+            datetime.strptime(start_str[:10], "%Y-%m-%d"),
+        )
+    if end_str:
+        end = timezone.make_aware(
+            datetime.strptime(end_str[:10], "%Y-%m-%d"),
+        )
+    return JsonResponse(
+        page.get_calendar_events(start=start, end=end),
+        safe=False
+    )
 
 
 @login_required

+ 15 - 0
docs/features/page_types/event_pages.rst

@@ -79,3 +79,18 @@ Next run ``python manage.py makemigrations website`` and ``python manage.py migr
 create the new pages in your project.
 
 Now when going to the wagtail admin, you can create an Event Landing Page, and child Event Pages.
+
+.. versionadded:: 0.22
+
+    All dates and times inputted via the Wagtail Admin, and rendered on the
+    calendar and throughout the site, will be converted to ``TIME_ZONE`` from
+    your Django settings. It is highly recommended to set ``TIME_ZONE`` and
+    ``USE_TZ = True`` in your Django settings for the Event pages to function
+    correctly.
+
+    For example, if ``TIME_ZONE`` is set to ``America/New_York``, then entering
+    an event for 2021-12-31 09:00 in the Wagtail admin will be saved as 9am New
+    York time. It will also be displayed on the website as 9am New York time.
+
+    If you then changed ``TIME_ZONE`` to ``America/Chicago``, the event time
+    will automatically be displayed as 8am Chicago time.

+ 11 - 0
docs/releases/v0.22.0.rst

@@ -24,6 +24,17 @@ Bug fixes
 * Cache is now cleared more reliably whenever pages or snippets are created,
   edited, published, unpublished, or deleted.
 
+* EventIndexPage now displays event times using the project ``TIME_ZONE``,
+  and other related improvements to event handling. See
+  :doc:`/features/page_types/event_pages`.
+
+
+Maintenance
+-----------
+
+* Updated calendar to fullcalendar.js 5.9. This removes several JavaScript
+  dependencies and also works towards removal of jQuery.
+
 
 Upgrade considerations
 ----------------------