From 64d6fe6404ee0db5cb56b39a3a4495fc177609ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Benjamin=20H=C3=A4ttasch?=
 <benjamin.haettasch@fachschaft.informatik.tu-darmstadt.de>
Date: Sun, 23 Oct 2022 21:40:27 +0200
Subject: [PATCH] Port availabilities editor to fullcalendar v5 and adjust
 usage

This used the common fullcalendar v5 instance instead of a special v3 version only for this editor
Improve visualization of availability deletion
Improve (event specific) timezone handling and remove dependency to moment-timezones
---
 AKModel/availability/models.py                |   2 +-
 AKModel/availability/serializers.py           |  11 +
 .../admin/AKModel/room_change_form.html       |  20 +-
 .../templates/AKSubmission/submit_new.html    |  19 +-
 .../AKSubmission/submit_new_wish.html         |  13 +-
 static_common/common/css/custom.css           |   9 +
 static_common/common/js/availabilities.js     | 215 +++++++++---------
 7 files changed, 158 insertions(+), 131 deletions(-)

diff --git a/AKModel/availability/models.py b/AKModel/availability/models.py
index 20a7a36a..4f90ddc2 100644
--- a/AKModel/availability/models.py
+++ b/AKModel/availability/models.py
@@ -21,7 +21,7 @@ zero_time = datetime.time(0, 0)
 # remove serialization as requirements are not covered
 # add translation
 # add meta class
-# enable availabilites for AKs and AKCategories
+# enable availabilities for AKs and AKCategories
 # add verbose names and help texts to model attributes
 class Availability(models.Model):
     """The Availability class models when people or rooms are available for.
diff --git a/AKModel/availability/serializers.py b/AKModel/availability/serializers.py
index 57657257..92b3adc6 100644
--- a/AKModel/availability/serializers.py
+++ b/AKModel/availability/serializers.py
@@ -2,6 +2,7 @@
 # Copyright 2017-2019, Tobias Kunze
 # Original Copyrights licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0
 # Changes are marked in the code
+from django.utils import timezone
 from rest_framework.fields import SerializerMethodField
 from rest_framework.serializers import ModelSerializer
 
@@ -10,10 +11,20 @@ from AKModel.availability.models import Availability
 
 class AvailabilitySerializer(ModelSerializer):
     allDay = SerializerMethodField()
+    start = SerializerMethodField()
+    end = SerializerMethodField()
 
     def get_allDay(self, obj):
         return obj.all_day
 
+    # Use already localized strings in serialized field
+    # (default would be UTC, but that would require heavy timezone calculation on client side)
+    def get_start(self, obj):
+        return timezone.localtime(obj.start, obj.event.timezone).strftime("%Y-%m-%dT%H:%M:%S")
+
+    def get_end(self, obj):
+        return timezone.localtime(obj.end, obj.event.timezone).strftime("%Y-%m-%dT%H:%M:%S")
+
     class Meta:
         model = Availability
         fields = ('id', 'start', 'end', 'allDay')
diff --git a/AKModel/templates/admin/AKModel/room_change_form.html b/AKModel/templates/admin/AKModel/room_change_form.html
index 4f305c4e..f2db1fbc 100644
--- a/AKModel/templates/admin/AKModel/room_change_form.html
+++ b/AKModel/templates/admin/AKModel/room_change_form.html
@@ -2,15 +2,23 @@
 {% load i18n admin_urls %}
 {% load static %}
 {% load bootstrap4 %}
+{% load tz %}
 
 {% block extrahead %}
     {{ block.super }}
     {% bootstrap_javascript jquery='slim' %}
-    <link href='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.css' %}' rel='stylesheet'/>
-    <link href='{% static 'AKSubmission/css/availabilities.css' %}' rel='stylesheet'/>
-
-    <script src="{% static "AKSubmission/vendor/moment/moment-with-locales.js" %}"></script>
-    <script src="{% static "AKSubmission/vendor/moment-timezone/moment-timezone-with-data-10-year-range.js" %}"></script>
-    <script src='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.js' %}'></script>
+    {% include "AKPlan/load_fullcalendar.html" %}
     <script src="{% static "common/js/availabilities.js" %}"></script>
+    <script>
+        {% get_current_language as LANGUAGE_CODE %}
+
+        document.addEventListener('DOMContentLoaded', function () {
+            createAvailabilityEditors(
+                '{{ original.event.timezone }}',
+                '{{ LANGUAGE_CODE }}',
+                '{{ original.event.start | timezone:original.event.timezone | date:"Y-m-d H:i:s" }}',
+                '{{ original.event.end | timezone:original.event.timezone | date:"Y-m-d H:i:s" }}'
+            );
+        });
+    </script>
 {% endblock %}
diff --git a/AKSubmission/templates/AKSubmission/submit_new.html b/AKSubmission/templates/AKSubmission/submit_new.html
index 42626533..60e387f7 100644
--- a/AKSubmission/templates/AKSubmission/submit_new.html
+++ b/AKSubmission/templates/AKSubmission/submit_new.html
@@ -4,19 +4,30 @@
 {% load bootstrap4 %}
 {% load fontawesome_5 %}
 {% load static %}
+{% load tz %}
 
 {% block title %}{% trans "AKs" %}: {{ event.name }} - {% trans "New AK" %}{% endblock %}
 
 {% block imports %}
     <link rel="stylesheet" href="{% static 'common/vendor/chosen-js/chosen.css' %}">
     <link rel="stylesheet" href="{% static 'common/css/bootstrap-chosen.css' %}">
-    <link href='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.css' %}' rel='stylesheet'/>
-    <link href='{% static 'AKSubmission/css/availabilities.css' %}' rel='stylesheet'/>
 
+    {% include "AKPlan/load_fullcalendar.html" %}
     <script src="{% static "AKSubmission/vendor/moment/moment-with-locales.js" %}"></script>
-    <script src="{% static "AKSubmission/vendor/moment-timezone/moment-timezone-with-data-10-year-range.js" %}"></script>
-    <script src='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.js' %}'></script>
     <script src="{% static "common/js/availabilities.js" %}"></script>
+
+    <script>
+    {% get_current_language as LANGUAGE_CODE %}
+
+    document.addEventListener('DOMContentLoaded', function () {
+        createAvailabilityEditors(
+            '{{ event.timezone }}',
+            '{{ LANGUAGE_CODE }}',
+            '{{ event.start | timezone:event.timezone | date:"Y-m-d H:i:s" }}',
+            '{{ event.end | timezone:event.timezone | date:"Y-m-d H:i:s" }}'
+        );
+    });
+    </script>
 {% endblock %}
 
 {% block breadcrumbs %}
diff --git a/AKSubmission/templates/AKSubmission/submit_new_wish.html b/AKSubmission/templates/AKSubmission/submit_new_wish.html
index 15a917d8..34e4bd83 100644
--- a/AKSubmission/templates/AKSubmission/submit_new_wish.html
+++ b/AKSubmission/templates/AKSubmission/submit_new_wish.html
@@ -6,17 +6,6 @@
 
 {% block title %}{% trans "AKs" %}: {{ event.name }} - {% trans "New AK Wish" %}{% endblock %}
 
-{% block imports %}
-    <link rel="stylesheet" href="{% static 'common/vendor/chosen-js/chosen.css' %}">
-    <link rel="stylesheet" href="{% static 'common/css/bootstrap-chosen.css' %}">
-    <link href='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.css' %}' rel='stylesheet'/>
-    <link href='{% static 'AKSubmission/css/availabilities.css' %}' rel='stylesheet'/>
-
-    <script src="{% static "AKSubmission/vendor/moment/moment-with-locales.js" %}"></script>
-    <script src="{% static "AKSubmission/vendor/moment-timezone/moment-timezone-with-data-10-year-range.js" %}"></script>
-    <script src='{% static 'AKSubmission/vendor/fullcalendar3/fullcalendar.min.js' %}'></script>
-    <script src="{% static "common/js/availabilities.js" %}"></script>
-{% endblock %}
 
 {% block breadcrumbs %}
     {% include "AKSubmission/submission_breadcrumbs.html" %}
@@ -27,4 +16,4 @@
 
 {% block headline %}
     <h2>{% trans 'New AK Wish' %}</h2>
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/static_common/common/css/custom.css b/static_common/common/css/custom.css
index eb1e18ca..d5ea6675 100644
--- a/static_common/common/css/custom.css
+++ b/static_common/common/css/custom.css
@@ -30,3 +30,12 @@
     word-break: normal;
     overflow-wrap: anywhere;
 }
+
+.deleteEvent {
+    background-color: #6a6a6a !important;
+}
+
+.deleteEvent .fc-event-title {
+    font-size: 5vw;
+    text-align: center;
+}
diff --git a/static_common/common/js/availabilities.js b/static_common/common/js/availabilities.js
index 253e0a66..49d3e2bf 100644
--- a/static_common/common/js/availabilities.js
+++ b/static_common/common/js/availabilities.js
@@ -1,126 +1,125 @@
-// This part of the code was adapted from pretalx (https://github.com/pretalx/pretalx)
+// Availability editor using fullcalendar v5
+
+// This code was initially based on the availability editor from pretalx (https://github.com/pretalx/pretalx)
 // Copyright 2017-2019, Tobias Kunze
 // Original Copyrights licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0
-// Changes are marked in the code
-document.addEventListener("DOMContentLoaded", function () {
-    "use strict"
+// It was significantly changed to deal with the newer fullcalendar version, event specific timezones,
+// to remove the dependency to moments timezone and improve the visualization of deletion
 
+function createAvailabilityEditors(timezone, language, startDate, endDate) {
     $("input.availabilities-editor-data").each(function () {
-        var data_field = $(this)
-        var editor = $('<div class="availabilities-editor">')
-        editor.attr("data-name", data_field.attr("name"))
-        data_field.after(editor)
+        const eventColor = '#28B62C';
 
-        function save_events() {
-            data = {
-                availabilities: editor.fullCalendar("clientEvents").map(function (e) {
-                    if (e.allDay) {
-                        return {
-                            start: e.start.format("YYYY-MM-DD HH:mm:ss"),
-                            end: e.end.format("YYYY-MM-DD HH:mm:ss"),
-                        }
-                    } else {
-                        return {
-                            start: e.start.toISOString(),
-                            end: e.end.toISOString(),
-                        }
-                    }
-                }),
-            }
-            data_field.attr("value", JSON.stringify(data))
-        }
+        let data_field = $(this);
+        let editor = $('<div class="availabilities-editor">');
+        editor.attr("data-name", data_field.attr("name"));
+        data_field.after(editor);
+        data_field.hide();
 
-        var editable = !Boolean(data_field.attr("disabled"))
+        let editable = !Boolean(data_field.attr("disabled"));
+        let data = JSON.parse(data_field.attr("value"));
+        let events = data.availabilities.map(function (e) {
+            start = moment(e.start);
+            end = moment(e.end);
+            allDay = start.format("HHmmss") === 0 && end.format("HHmmss") === 0;
 
-        var data = JSON.parse(data_field.attr("value"))
-        var events = data.availabilities.map(function (e) {
-            e.start = moment(e.start)//.tz(data.event.timezone)
-            e.end = moment(e.end)//.tz(data.event.timezone)
+            return {
+                id: e.id,
+                start: start.format(),
+                end: end.format(),
+                allDay: allDay,
+                title: ""
+            };
+        });
 
-            if (e.start.format("HHmmss") == 0 && e.end.format("HHmmss") == 0) {
-                e.allDay = true
-            }
+        let eventMarkedForDeletion = undefined;
+        let eventMarkedForDeletionEl = undefined;
+        let newEventsCounter = 0;
 
-            return e
-        })
-        editor.fullCalendar({
+        let plan = new FullCalendar.Calendar(editor[0], {
+            timeZone: timezone,
+            themeSystem: 'bootstrap',
+            locale: language,
+            schedulerLicenseKey: 'GPL-My-Project-Is-Open-Source',
+            editable: editable,
+            selectable: editable,
+            headerToolbar: false,
+            initialView: 'timeGridWholeEvent',
             views: {
-                agendaVariableDays: {
-                    type: "agenda",
-                    duration: {
-                        days:
-                            moment(data.event.date_to).diff(
-                                moment(data.event.date_from),
-                                "days"
-                            ) + 1,
+                timeGridWholeEvent: {
+                    type: 'timeGrid',
+                    visibleRange: {
+                        start: startDate,
+                        end: endDate,
                     },
-                },
-            },
-            defaultView: "agendaVariableDays",
-            defaultDate: data.event.date_from,
-            visibleRange: {
-                start: data.event.date_from,
-                end: data.event.date_to,
+                }
             },
-            events: events,
-            nowIndicator: false,
-            navLinks: false,
-            header: false,
-            timeFormat: "H:mm",
-            slotLabelFormat: "H:mm",
-            scrollTime: "09:00:00",
-            selectable: editable,
-            selectHelper: true,
-            select: function (start, end) {
-                var wasInDeleteMode = false
-                editor.fullCalendar("clientEvents").forEach(function (e) {
-                    if (e.className.indexOf("delete") >= 0) {
-                        wasInDeleteMode = true
-                    }
-                    e.className = ""
-                    editor.fullCalendar("updateEvent", e)
+            allDaySlot: true,
+            events: data.availabilities,
+            eventBackgroundColor: eventColor,
+            select: function (info) {
+                resetDeletionCandidate();
+                plan.addEvent({
+                    title: "",
+                    start: info.start,
+                    end: info.end,
+                    id: 'new' + newEventsCounter
                 })
-
-                if (wasInDeleteMode) {
-                    editor.fullCalendar("unselect")
-                    return
-                }
-
-                var eventData = {
-                    start: start,
-                    end: end,
+                newEventsCounter++;
+                save_events();
+            },
+            eventClick: function (info) {
+                if (eventMarkedForDeletion !== undefined && (eventMarkedForDeletion.id === info.event.id)) {
+                    info.event.remove();
+                    eventMarkedForDeletion = undefined;
+                    eventMarkedForDeletionEl = undefined;
+                    save_events();
+                } else {
+                    resetDeletionCandidate();
+                    makeDeletionCandidate(info.el);
+                    eventMarkedForDeletion = info.event;
+                    eventMarkedForDeletionEl = info.el;
                 }
-                editor.fullCalendar("renderEvent", eventData, true)
-                editor.fullCalendar("unselect")
-                save_events()
             },
-            eventResize: save_events,
-            eventDrop: save_events,
-            editable: editable,
             selectOverlap: false,
             eventOverlap: false,
-            eventColor: "#00DD00",
-            eventClick: function (calEvent, jsEvent, view) {
-                if (!editable) {
-                    return
-                }
+            eventChange: save_events,
+        });
+        plan.render();
 
-                if (calEvent.className.indexOf("delete") >= 0) {
-                    editor.fullCalendar("removeEvents", function (searchEvent) {
-                        return searchEvent._id === calEvent._id
-                    })
-                    save_events()
-                } else {
-                    editor.fullCalendar("clientEvents").forEach(function (e) {
-                        if (e._id == calEvent._id) {
-                            e.className = "delete"
-                        } else {
-                            e.className = ""
-                        }
-                        editor.fullCalendar("updateEvent", e)
-                    })
-                }
-            },
-        })
-    })
-})
+        function makeDeletionCandidate(el) {
+            el.classList.add("deleteEvent");
+            $(el).find(".fc-event-title").html("<i class='fas fa-trash'></i> <i class='fas fa-question'></i>");
+        }
+
+        function resetDeletionCandidate() {
+            if (eventMarkedForDeletionEl !== undefined) {
+                eventMarkedForDeletionEl.classList.remove("deleteEvent");
+                $(eventMarkedForDeletionEl).find(".fc-event-title").html("");
+            }
+            eventMarkedForDeletionEl = undefined;
+            eventMarkedForDeletion = undefined;
+        }
+
+        function save_events() {
+            data = {
+                availabilities: plan.getEvents().map(function (e) {
+                    let id = e.id;
+                    if(e.id.startsWith("new"))
+                        id = "";
+                    return {
+                        id: id,
+                        // Make sure these timestamps are correctly interpreted as localized ones
+                        // by removing the UTC-signaler ("Z" at the end)
+                        // A bit dirty, but still more elegant than creating a timestamp with the
+                        // required format manually
+                        start: e.start.toISOString().replace("Z", ""),
+                        end: e.end.toISOString().replace("Z", ""),
+                        allDay: e.allDay,
+                    }
+                }),
+            }
+            data_field.attr("value", JSON.stringify(data));
+        }
+    });
+}
-- 
GitLab