Repository: ephios-dev/ephios

Issue:
Display events as calendar view
As a user, I expect the system to have a view where i can browse a calendar view with the events. There should be some information rendered for each event and I expect to be redirected to the event detail page on click.

As a manager I expect to have a setting where I can choose if the calendar or the list view should be the default.


Discussion:

Resolving patch:
diff --git a/ephios/core/calendar.py b/ephios/core/calendar.py
new file mode 100644
--- /dev/null
+++ b/ephios/core/calendar.py
@@ -0,0 +1,50 @@
+from calendar import HTMLCalendar, day_abbr
+from datetime import date, datetime
+from itertools import groupby
+
+from django.utils.formats import date_format
+from django.utils.translation import gettext as _
+
+
+class ShiftCalendar(HTMLCalendar):
+    cssclass_month = "table table-fixed"
+
+    def __init__(self, shifts, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.shifts = {
+            k: list(v) for (k, v) in groupby(shifts, lambda shift: shift.start_time.date().day)
+        }
+
+    def formatmonth(self, theyear, themonth, withyear=True):
+        self.year, self.month = theyear, themonth
+        return super().formatmonth(theyear, themonth)
+
+    def formatmonthname(self, theyear, themonth, withyear=True):
+        dt = datetime(theyear, themonth, 1)
+        return '<tr><th colspan="7" class="month">{monthyear}</th></tr>'.format(
+            monthyear=date_format(dt, format="b Y")
+        )
+
+    def formatweekday(self, day):
+        return '<th class="{cssclasses}">{day}</th>'.format(
+            cssclasses=self.cssclasses[day], day=_(day_abbr[day])
+        )
+
+    def formatday(self, day, weekday):
+        if day != 0:
+            cssclass = self.cssclasses[weekday]
+            if date.today() == date(self.year, self.month, day):
+                cssclass += " calendar-today"
+            if day in self.shifts:
+                cssclass += " filled"
+                body = ["<br />"]
+                for shift in self.shifts[day]:
+                    body.append('<a href="%s">' % shift.event.get_absolute_url())
+                    body.append(shift.event.title)
+                    body.append("</a><br />")
+                return self.day_cell(cssclass, "%d %s" % (day, "".join(body)))
+            return self.day_cell(cssclass, day)
+        return self.day_cell("noday", "&nbsp;")
+
+    def day_cell(self, cssclass, body):
+        return '<td class="calendar-row-height p-1 break-word %s">%s</td>' % (cssclass, body)
diff --git a/ephios/core/urls.py b/ephios/core/urls.py
--- a/ephios/core/urls.py
+++ b/ephios/core/urls.py
@@ -25,11 +25,12 @@
     EventCreateView,
     EventDeleteView,
     EventDetailView,
-    EventListView,
+    EventListTypeSettingView,
     EventNotificationView,
     EventUpdateView,
     HomeView,
     RRuleOccurrenceView,
+    current_event_list_view,
 )
 from ephios.core.views.eventtype import (
     EventTypeCreateView,
@@ -51,7 +52,7 @@
 app_name = "core"
 urlpatterns = [
     path("", HomeView.as_view(), name="index"),
-    path("events/", EventListView.as_view(), name="event_list"),
+    path("events/", current_event_list_view, name="event_list"),
     path(
         "events/<int:pk>/edit/",
         EventUpdateView.as_view(),
@@ -205,4 +206,9 @@
     path("manifest.json", manifest, name="pwa_manifest"),
     path("serviceworker.js", serviceworker, name="pwa_serviceworker"),
     path("offline/", offline, name="pwa_offline"),
+    path(
+        "events/list_type_setting",
+        EventListTypeSettingView.as_view(),
+        name="event_list_type_setting",
+    ),
 ]
diff --git a/ephios/core/views/event.py b/ephios/core/views/event.py
--- a/ephios/core/views/event.py
+++ b/ephios/core/views/event.py
@@ -1,5 +1,6 @@
 import functools
 import json
+from calendar import _nextmonth, _prevmonth
 from datetime import datetime, timedelta
 
 from django.contrib import messages
@@ -12,6 +13,7 @@
 from django.urls import reverse, reverse_lazy
 from django.utils import timezone
 from django.utils.formats import date_format
+from django.utils.safestring import mark_safe
 from django.utils.timezone import get_current_timezone, make_aware
 from django.utils.translation import gettext as _
 from django.views import View
@@ -21,6 +23,7 @@
     DetailView,
     FormView,
     ListView,
+    RedirectView,
     TemplateView,
     UpdateView,
 )
@@ -28,6 +31,7 @@
 from guardian.shortcuts import assign_perm, get_objects_for_user, get_users_with_perms
 from recurrence.forms import RecurrenceField
 
+from ephios.core.calendar import ShiftCalendar
 from ephios.core.forms.events import EventDuplicationForm, EventForm, EventNotificationForm
 from ephios.core.models import Event, EventType, Shift
 from ephios.core.services.notifications.types import (
@@ -40,6 +44,12 @@
 from ephios.extra.permissions import get_groups_with_perms
 
 
+def current_event_list_view(request):
+    if request.session.get("event_list_view_type", "list") == "calendar":
+        return EventCalendarView.as_view()(request)
+    return EventListView.as_view()(request)
+
+
 class EventListView(LoginRequiredMixin, ListView):
     model = Event
 
@@ -316,3 +326,33 @@ def form_valid(self, form):
             CustomEventParticipantNotification.send(self.object, form.cleaned_data["mail_content"])
         messages.success(self.request, _("Notifications sent succesfully."))
         return redirect(self.object.get_absolute_url())
+
+
+class EventCalendarView(TemplateView):
+    template_name = "core/event_calendar.html"
+
+    def get_context_data(self, **kwargs):
+        today = datetime.today()
+        year = int(self.request.GET.get("year", today.year))
+        month = int(self.request.GET.get("month", today.month))
+        shifts = Shift.objects.filter(start_time__month=month, start_time__year=year)
+        calendar = ShiftCalendar(shifts)
+        kwargs.setdefault("calendar", mark_safe(calendar.formatmonth(year, month)))
+        nextyear, nextmonth = _nextmonth(year, month)
+        kwargs.setdefault(
+            "next_month_url", f"{reverse('core:event_list')}?year={nextyear}&month={nextmonth}"
+        )
+        prevyear, prevmonth = _prevmonth(year, month)
+        kwargs.setdefault(
+            "previous_month_url", f"{reverse('core:event_list')}?year={prevyear}&month={prevmonth}"
+        )
+        return super().get_context_data(**kwargs)
+
+
+class EventListTypeSettingView(RedirectView):
+    def get_redirect_url(self, *args, **kwargs):
+        return reverse("core:event_list")
+
+    def post(self, request, *args, **kwargs):
+        request.session["event_list_view_type"] = request.POST.get("event_list_view_type")
+        return self.get(request, *args, **kwargs)


Test patch:
diff --git a/tests/core/test_event_bulk_actions.py b/tests/core/test_event_bulk_actions.py
--- a/tests/core/test_event_bulk_actions.py
+++ b/tests/core/test_event_bulk_actions.py
@@ -6,7 +6,7 @@
 class TestEventBulkDelete:
     def test_empty_list(self, django_app, planner, event, groups):
         event_count = Event.objects.count()
-        form = django_app.get(reverse("core:event_list"), user=planner).form
+        form = django_app.get(reverse("core:event_list"), user=planner).forms["bulk_action_form"]
         form.action = reverse(
             "core:event_bulk_delete"
         )  # webtest cannot read the formaction from button
@@ -15,7 +15,7 @@ def test_empty_list(self, django_app, planner, event, groups):
 
     def test_delete_multiple_events(self, django_app, planner, event, multi_shift_event, groups):
         event_count = Event.objects.count()
-        form = django_app.get(reverse("core:event_list"), user=planner).form
+        form = django_app.get(reverse("core:event_list"), user=planner).forms["bulk_action_form"]
         form.action = reverse(
             "core:event_bulk_delete"
         )  # webtest cannot read the formaction from button