Selaa lähdekoodia

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 vuotta sitten
vanhempi
commit
263cd67e8d

+ 73 - 25
coderedcms/models/page_models.py

@@ -6,8 +6,8 @@ import json
 import logging
 import logging
 import os
 import os
 import warnings
 import warnings
-from datetime import datetime
+from datetime import date, datetime
-from typing import Optional, TYPE_CHECKING
+from typing import Dict, List, Optional, TYPE_CHECKING, Union
 
 
 import geocoder
 import geocoder
 from django import forms
 from django import forms
@@ -912,21 +912,6 @@ class CoderedEventPage(CoderedWebPage, BaseEvent):
         return events
         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):
 class CoderedEventIndexPage(CoderedWebPage):
     """
     """
     Shows a list of event sub-pages.
     Shows a list of event sub-pages.
@@ -935,6 +920,18 @@ class CoderedEventIndexPage(CoderedWebPage):
         verbose_name = _('CodeRed Event Index Page')
         verbose_name = _('CodeRed Event Index Page')
         abstract = True
         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'
     template = 'coderedcms/pages/event_index_page.html'
 
 
     index_show_subpages_default = True
     index_show_subpages_default = True
@@ -946,16 +943,55 @@ class CoderedEventIndexPage(CoderedWebPage):
 
 
     default_calendar_view = models.CharField(
     default_calendar_view = models.CharField(
         blank=True,
         blank=True,
-        choices=DefaultCalendarViewChoices.CHOICES,
+        choices=CalendarViews.choices,
+        default=CalendarViews.MONTH,
         max_length=255,
         max_length=255,
         verbose_name=_('Calendar Style'),
         verbose_name=_('Calendar Style'),
         help_text=_('The default look of the calendar on this page.')
         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 + [
     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):
     def get_index_children(self):
         if self.index_query_pagemodel and self.index_order_by == 'next_occurrence':
         if self.index_query_pagemodel and self.index_order_by == 'next_occurrence':
             querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
             querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
@@ -965,12 +1001,20 @@ class CoderedEventIndexPage(CoderedWebPage):
             for event in qs.all():
             for event in qs.all():
                 if event.next_occurrence():
                 if event.next_occurrence():
                     upcoming.append(event)
                     upcoming.append(event)
-            # sort the events by next_occurrence
+            # Sort the events by next_occurrence start date.
-            return sorted(upcoming, key=lambda e: e.next_occurrence())
+            return sorted(upcoming, key=lambda e: e.next_occurrence()[0])
 
 
         return super().get_index_children()
         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.
         # start with all child events, regardless of get_index_children rules.
         querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
         querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
         qs = querymodel.objects.child_of(self).live()
         qs = querymodel.objects.child_of(self).live()
@@ -978,16 +1022,20 @@ class CoderedEventIndexPage(CoderedWebPage):
         for event in qs:
         for event in qs:
             occurrences = event.query_occurrences(limit=None, from_date=start, to_date=end)
             occurrences = event.query_occurrences(limit=None, from_date=start, to_date=end)
             for occurrence in occurrences:
             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 = {
                 event_data = {
                     'title': event.title,
                     'title': event.title,
-                    'start': occurrence[0].strftime('%Y-%m-%dT%H:%M:%S'),
+                    'start': local_start.strftime('%Y-%m-%dT%H:%M:%S%z'),
-                    'end': occurrence[1].strftime('%Y-%m-%dT%H:%M:%S') if occurrence[1] else "",
+                    'end': local_end.strftime('%Y-%m-%dT%H:%M:%S%z') if local_end else "",
                     'description': "",
                     'description': "",
                 }
                 }
                 if event.url:
                 if event.url:
                     event_data['url'] = event.url
                     event_data['url'] = event.url
                 if event.calendar_color:
                 if event.calendar_color:
-                    event_data['backgroundColor'] = event.calendar_color
+                    event_data['color'] = event.calendar_color
                 event_instances.append(event_data)
                 event_instances.append(event_data)
         return event_instances
         return event_instances
 
 

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

@@ -1,6 +1,6 @@
 /*
 /*
 CodeRed CMS (https://www.coderedcorp.com/cms/)
 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: https://github.com/coderedcorp/coderedcms/blob/dev/LICENSE
 @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
 @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
 */
 */
@@ -29,19 +29,10 @@ libs = {
         integrity: "sha256-mvFcf2wocDC8U1GJdTVSmMHBn/dBLNeJjYRvBhM6gc8=",
         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" />'
         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: {
     fullcalendar: {
-        url: "https://cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.10.0/fullcalendar.min.js",
+        url: "https://cdn.jsdelivr.net/npm/fullcalendar@5.9.0/main.min.js",
-        integrity: "sha256-4+rW6N5lf9nslJC6ut/ob7fCY2Y+VZj2Pw/2KdmQjR0=",
+        integrity: "sha256-8nl2O4lMNahIAmUnxZprMxJIBiPv+SzhMuYwEuinVM0=",
-        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" />' +
+        head: '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@5.9.0/main.min.css" integrity="sha256-FjyLCG3re1j4KofUTQQXmaWJw13Jdb7LQvXlkFxTDJI=" 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" />'
     },
     },
     coderedmaps: {
     coderedmaps: {
         url: "/static/coderedcms/js/codered-maps.js",
         url: "/static/coderedcms/js/codered-maps.js",
@@ -156,37 +147,40 @@ $(document).ready(function()
 
 
     /*** Calendar **/
     /*** Calendar **/
     if ( $("[data-block='calendar']").length > 0){
     if ( $("[data-block='calendar']").length > 0){
-        load_script(libs.jquery_ui, function(){
+        load_script(libs.fullcalendar, function(){
-            load_script(libs.jquery_qtip, function(){
+            var calendars = document.querySelectorAll("[data-block='calendar']");
-                load_script(libs.moment, function(){
+            calendars.forEach(function(el){
-                    load_script(libs.fullcalendar, function(){
+                var pageId = el.dataset.pageId; // data-page-id
-                        var pageId = "";
+                var defaultDate = el.dataset.defaultDate; // data-default-date
-                        var defaultDate = "";
+                var defaultView = el.dataset.defaultView; // data-default-view
-                        var defaultView = "";
+                var eventDisplay = el.dataset.eventDisplay; // data-event-display
-                        $('[data-block="calendar"]').each(function(index, obj){
+                var timezone = el.dataset.timezone; // data-timezone
-                            pageId = $(obj).data('page-id');
+                var calendar = new FullCalendar.Calendar(el, {
-                            defaultDate = $(obj).data('default-date');
+                    headerToolbar: {
-                            defaultView = $(obj).data('default-view');
+                        left: 'prev,next today',
-                            $(obj).fullCalendar({
+                        center: 'title',
-                                header: {
+                        right: 'dayGridMonth,timeGridWeek,timeGridDay,listMonth'
-                                    left: 'prev,next,today',
+                    },
-                                    center: 'title',
+                    themeSystem: 'bootstrap',
-                                    right: 'month,agendaWeek,agendaDay,listMonth'
+                    bootstrapFontAwesome: false,
-                                },
+                    buttonText: {
-                                defaultDate: defaultDate,
+                        'prev': '< prev',
-                                defaultView: defaultView,
+                        'next': 'next >'
-                                fixedWeekCount: false,
+                    },
-                                events: {
+                    initialDate: defaultDate,
-                                    url: '/ajax/calendar/events/',
+                    initialView: defaultView,
-                                    type: 'GET',
+                    fixedWeekCount: false,
-                                    data: {
+                    timeZone: timezone,
-                                        'pid': pageId
+                    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">
+<form class="calendar-ical-form" action="{% url 'event_generate_ical_for_calendar' %}" method="POST">
-        <input name="page_id" type="number" hidden value="{{ page.id }}" />
+    <input name="page_id" type="number" hidden value="{{ page.id }}" />
-        {% csrf_token %}
+    {% csrf_token %}
-    <button class="btn btn-primary" title="Download calendar in .ical format" type="submit">{% block button_text %}Download Calendar{% endblock %}</button>
+    <button class="btn btn-primary" title="Download calendar in .ics format" type="submit">
+        {% block button_text %}Download Calendar (ical){% endblock %}
+    </button>
 </form>
 </form>

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

@@ -7,9 +7,7 @@
     <div class="container">
     <div class="container">
         <div class="row">
         <div class="row">
             <div class="col pt-5 pb-5">
             <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 }}">
+                {% include 'coderedcms/includes/ical/calendar.html' %}
-                    <noscript>Javascript is required to view the Calendar.</noscript>
-                </div>
             </div>
             </div>
         </div>
         </div>
         <div class="row">
         <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.urls import reverse
 from django.test import Client
 from django.test import Client
 from django.test.utils import override_settings
 from django.test.utils import override_settings
+from django.utils import timezone
 
 
 from wagtail.core.models import Site, Page
 from wagtail.core.models import Site, Page
 from wagtail.images.tests.utils import Image, get_test_image_file
 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
         # Get datetimes from response and compare them to datetimes on page
         start = literal_eval(response._container[0].decode()[1:-1])['start']
         start = literal_eval(response._container[0].decode()[1:-1])['start']
         end = literal_eval(response._container[0].decode()[1:-1])['end']
         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(
         self.assertEqual(
             start,
             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(
         self.assertEqual(
             end,
             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.contrib.contenttypes.models import ContentType
 from django.core.paginator import Paginator, InvalidPage, EmptyPage, PageNotAnInteger
 from django.core.paginator import Paginator, InvalidPage, EmptyPage, PageNotAnInteger
 from django.shortcuts import redirect, render
 from django.shortcuts import redirect, render
+from django.utils import timezone
 from django.utils.translation import ungettext, gettext_lazy as _
 from django.utils.translation import ungettext, gettext_lazy as _
 from icalendar import Calendar
 from icalendar import Calendar
 from wagtail.admin import messages
 from wagtail.admin import messages
@@ -185,7 +186,6 @@ def event_generate_ical_for_calendar(request):
     if request.method == "POST":
     if request.method == "POST":
         try:
         try:
             page = CoderedPage.objects.get(id=request.POST.get('page_id')).specific
             page = CoderedPage.objects.get(id=request.POST.get('page_id')).specific
-            print(page)
         except ValueError:
         except ValueError:
             raise Http404
             raise Http404
 
 
@@ -201,17 +201,29 @@ def event_generate_ical_for_calendar(request):
 
 
 
 
 def event_get_calendar_events(request):
 def event_get_calendar_events(request):
-    if request.is_ajax():
+    """
-        try:
+    JSON list of events compatible with fullcalendar.js
-            page = CoderedPage.objects.get(id=request.GET.get('pid')).specific
+    """
-        except ValueError:
+    try:
-            raise Http404
+        page = CoderedPage.objects.get(id=request.GET.get('pid')).specific
-        start_str = request.GET.get('start')
+    except ValueError:
-        start = datetime.strptime(start_str[:10], "%Y-%m-%d") if start_str else None
+        raise Http404
-        end_str = request.GET.get('end')
+    start = None
-        end = datetime.strptime(end_str[:10], "%Y-%m-%d") if end_str else None
+    end = None
-        return JsonResponse(page.get_calendar_events(start=start, end=end), safe=False)
+    start_str = request.GET.get('start', None)
-    raise Http404()
+    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
 @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.
 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.
 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,
 * Cache is now cleared more reliably whenever pages or snippets are created,
   edited, published, unpublished, or deleted.
   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
 Upgrade considerations
 ----------------------
 ----------------------