From 9795ca99c6d2695887604dff7f73a3d2c07ba4b9 Mon Sep 17 00:00:00 2001
From: OndraRehounek <ondra.rehounek@seznam.cz>
Date: Thu, 5 May 2022 15:24:29 +0200
Subject: [PATCH] calendar_utils: Replace ics library with icalevnt

---
 .isort.cfg                                    |  2 +-
 ...ove_calendar_source_calendar_event_hash.py | 22 +++++
 .../migrations/0004_auto_20220505_1228.py     | 42 +++++++++
 calendar_utils/models.py                      | 33 ++++---
 calendar_utils/parser.py                      | 89 ++++++++++---------
 district/templates/district/base.html         |  4 +-
 .../elections2021_calendar_page.html          |  4 +-
 .../elections2021_home_page.html              |  4 +-
 region/templates/region/base.html             |  4 +-
 requirements/base.in                          |  1 +
 requirements/base.txt                         | 25 ++++--
 .../calendar_event_snippet.html               |  6 +-
 .../calendar_current_events_snippet.html      | 10 +--
 .../shared/small_calendar_snippet.html        |  6 +-
 .../uniweb/blocks/calendar_agenda.html        |  6 +-
 15 files changed, 175 insertions(+), 83 deletions(-)
 create mode 100644 calendar_utils/migrations/0003_remove_calendar_source_calendar_event_hash.py
 create mode 100644 calendar_utils/migrations/0004_auto_20220505_1228.py

diff --git a/.isort.cfg b/.isort.cfg
index 575531da..eadf7557 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -3,4 +3,4 @@
 line_length = 88
 multi_line_output = 3
 include_trailing_comma = true
-known_third_party = PyPDF2,arrow,bleach,bs4,captcha,celery,django,environ,faker,fastjsonschema,ics,markdown,modelcluster,pirates,pytest,pytz,requests,sentry_sdk,snapshottest,taggit,wagtail,wagtailmetadata,weasyprint,yaml
+known_third_party = PyPDF2,arrow,bleach,bs4,captcha,celery,django,environ,faker,fastjsonschema,icalevnt,markdown,modelcluster,pirates,pytest,pytz,requests,sentry_sdk,snapshottest,taggit,wagtail,wagtailmetadata,weasyprint,yaml,zoneinfo
diff --git a/calendar_utils/migrations/0003_remove_calendar_source_calendar_event_hash.py b/calendar_utils/migrations/0003_remove_calendar_source_calendar_event_hash.py
new file mode 100644
index 00000000..6c4c356d
--- /dev/null
+++ b/calendar_utils/migrations/0003_remove_calendar_source_calendar_event_hash.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.0.4 on 2022-05-05 10:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("calendar_utils", "0002_auto_20200523_0243"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="calendar",
+            name="source",
+        ),
+        migrations.AddField(
+            model_name="calendar",
+            name="event_hash",
+            field=models.CharField(max_length=256, null=True),
+        ),
+    ]
diff --git a/calendar_utils/migrations/0004_auto_20220505_1228.py b/calendar_utils/migrations/0004_auto_20220505_1228.py
new file mode 100644
index 00000000..f8ff43ff
--- /dev/null
+++ b/calendar_utils/migrations/0004_auto_20220505_1228.py
@@ -0,0 +1,42 @@
+# Generated by Django 4.0.4 on 2022-05-05 10:28
+from datetime import date, timedelta
+
+import arrow
+from django.db import migrations
+from icalevnt import icalevents
+
+from calendar_utils.parser import process_event_list
+
+
+def update_event_lists(apps, schema):
+    Calendar = apps.get_model("calendar_utils", "Calendar")
+
+    for calendar in Calendar.objects.all():
+        try:
+            event_list = icalevents.events(
+                url=calendar.url,
+                start=date.today() - timedelta(days=30),
+                end=date.today() + timedelta(days=60),
+            )
+        except ValueError:
+            print("Could not parse calendar from {}".format(calendar.url))
+
+        event_list_hash = str(hash(str(event_list)))
+        past, future = process_event_list(event_list)
+        calendar.past_events = past
+        calendar.future_events = list(reversed(future))
+        calendar.event_hash = event_list_hash
+
+        calendar.last_update = arrow.utcnow().datetime
+        calendar.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("calendar_utils", "0003_remove_calendar_source_calendar_event_hash"),
+    ]
+
+    operations = [
+        migrations.RunPython(update_event_lists, reverse_code=migrations.RunPython.noop)
+    ]
diff --git a/calendar_utils/models.py b/calendar_utils/models.py
index 53f92e8a..d0717399 100644
--- a/calendar_utils/models.py
+++ b/calendar_utils/models.py
@@ -1,15 +1,15 @@
-import re
+from datetime import date, timedelta
 
 import arrow
-import requests
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
+from icalevnt import icalevents
 
-from .parser import process_ical
+from .parser import process_event_list
 
 
 def _convert_arrow_to_datetime(event):
-    event["begin"] = event["begin"].datetime
+    event["start"] = event["start"].datetime
     event["end"] = event["end"].datetime
     return event
 
@@ -24,7 +24,7 @@ class EventsJSONField(models.JSONField):
         value = super().from_db_value(value, expression, connection)
         if value:
             for event in value:
-                event["begin"] = arrow.get(event["begin"]).datetime
+                event["start"] = arrow.get(event.get("start")).datetime
                 event["end"] = arrow.get(event["end"]).datetime
         return value
 
@@ -33,20 +33,25 @@ class Calendar(models.Model):
     CURRENT_NUM = 6
 
     url = models.URLField()
-    source = models.TextField(null=True)
+    event_hash = models.CharField(max_length=256, null=True)
     last_update = models.DateTimeField(null=True)
     past_events = EventsJSONField(encoder=DjangoJSONEncoder, null=True)
     future_events = EventsJSONField(encoder=DjangoJSONEncoder, null=True)
 
     def update_source(self):
-        source = requests.get(self.url).text
-        if self.source != source:
-            self.source = source
-            past, future = process_ical(source)
-            self.past_events = list(map(_convert_arrow_to_datetime, past))
-            self.future_events = list(
-                reversed(list(map(_convert_arrow_to_datetime, future)))
-            )
+        event_list = icalevents.events(
+            url=self.url,
+            start=date.today() - timedelta(days=30),
+            end=date.today() + timedelta(days=60),
+        )
+
+        event_list_hash = str(hash(str(event_list)))
+        if event_list_hash != self.event_hash:
+            past, future = process_event_list(event_list)
+            self.past_events = past
+            self.future_events = list(reversed(future))
+            self.event_hash = event_list_hash
+
         self.last_update = arrow.utcnow().datetime
         self.save()
 
diff --git a/calendar_utils/parser.py b/calendar_utils/parser.py
index c7c453c4..978ca57a 100644
--- a/calendar_utils/parser.py
+++ b/calendar_utils/parser.py
@@ -1,74 +1,81 @@
-import re
-from operator import attrgetter
+from operator import itemgetter
+from typing import TYPE_CHECKING
 
 import arrow
 import bleach
 from django.conf import settings
-from ics import Calendar
+from zoneinfo import ZoneInfo
 
-EVENT_KEYS = ("begin", "end", "all_day", "name", "description", "location")
+if TYPE_CHECKING:
+    from icalevnt.icalparser import Event
 
+# FIXME "name" is "summary" and "begin" is "start" now
+EVENT_KEYS = ("start", "end", "all_day", "summary", "description", "location")
 
-def remove_alarms(source):
-    """Removes VALARM blocks from iCal source."""
-    return re.sub(r"(BEGIN:VALARM.*?END:VALARM\r?\n)", "", source, flags=re.S)
 
-
-def parse_ical(source):
-    """Parses iCalendar source and returns events as list of dicts."""
-    # there is a bug in parsing alarms, but we don't need them, so we remove them
-    source = remove_alarms(source)
-    cal = Calendar(source)
-    events = []
-    for event in sorted(cal.events, key=attrgetter("begin"), reverse=True):
-        events.append({key: getattr(event, key) for key in EVENT_KEYS})
-    return events
-
-
-def split_events(events):
+def split_event_dict_list(event_list: "list[dict]") -> tuple[list[dict], list[dict]]:
     """Splits events and returns list of past events and future events."""
     singularity = arrow.utcnow().shift(hours=-2)
-    past = [ev for ev in events if ev["end"] < singularity]
-    future = [ev for ev in events if ev["end"] > singularity]
+
+    past = [ev for ev in event_list if ev["end"] < singularity]
+    future = [ev for ev in event_list if ev["end"] > singularity]
+
     return past, future
 
 
-def set_event_description(event):
+def set_event_description(event: "Event") -> "Event":
     """Clears even description from unwanted tags."""
-    description: str = event.get("description", "") or ""
-    event["description"] = bleach.clean(description, tags=["a", "br"], strip=True)
+    description: str = event.description or ""
+    event.description = bleach.clean(description, tags=["a", "br"], strip=True)
     return event
 
 
-def set_event_duration(event):
+def set_event_duration(event: "Event") -> "Event":
     """Sets duration for event."""
-    if event["all_day"]:
-        event["duration"] = "celý den"
+    if event.all_day:
+        event.duration = "celý den"
         return event
 
-    delta = event["end"] - event["begin"]
+    delta = event.end - event.start
     if delta.days < 1:
-        begin = event["begin"].to(settings.TIME_ZONE).format("H:mm")
-        end = event["end"].to(settings.TIME_ZONE).format("H:mm")
-        event["duration"] = f"{begin} - {end}"
+        start = event.start.strftime("%H:%M")  # .to(settings.TIME_ZONE).format("H:mm")
+        end = event.end.strftime("%H:%M")
+        event.duration = f"{start} - {end}"
     else:
-        begin = event["begin"].to(settings.TIME_ZONE).format("H:mm")
-        end = event["end"].to(settings.TIME_ZONE).format("H:mm (D.M.)")
-        event["duration"] = f"{begin} - {end}"
+        start = event.start.strftime("%H:%M")
+        end = event.end.strftime("%H:%M")
+        event.duration = f"{start} - {end}"
     return event
 
 
-def process_event(event):
+def set_event_timezone(event: "Event") -> "Event":
+    """Sets default project timezone for event if missing."""
+    if (
+        not event.start.tzinfo
+        or not event.start.tzinfo.utcoffset(event.start)
+        or not event.end.tzinfo
+        or not event.end.tzinfo.utcoffset(event.end)
+    ):
+        event.start = event.start.replace(tzinfo=ZoneInfo(settings.TIME_ZONE))
+        event.end = event.end.replace(tzinfo=ZoneInfo(settings.TIME_ZONE))
+    return event
+
+
+def process_event(event: "Event") -> dict:
     """Processes single event for use in Majak"""
+    event = set_event_timezone(event)
     event = set_event_duration(event)
     event = set_event_description(event)
-    return event
+    # for event in sorted(cal.events, key=attrgetter("start"), reverse=True): TODO check
+    return {key: getattr(event, key) for key in EVENT_KEYS}
 
 
-def process_ical(source):
+def process_event_list(event_list: "list[Event]") -> tuple[list[dict], list[dict]]:
     """Parses iCalendar source and returns events as list of dicts. Returns
     tuple of past and future events.
     """
-    events = parse_ical(source)
-    events = list(map(process_event, events))
-    return split_events(events)
+    processed_event_list = list(map(process_event, event_list))
+    processed_event_list = sorted(
+        processed_event_list, key=itemgetter("start"), reverse=True
+    )
+    return split_event_dict_list(processed_event_list)
diff --git a/district/templates/district/base.html b/district/templates/district/base.html
index 0d5f9d45..ce022f9a 100644
--- a/district/templates/district/base.html
+++ b/district/templates/district/base.html
@@ -175,8 +175,8 @@
                   {% if first_event %}
                     <span class="btn text-sm max-w-full hidden lg:block" @click="toggleView('calendar')">
                       <div class="btn__body bg-grey-800 text-grey-200 flex divide-x">
-                        <span class="pr-4">{{ first_event.name }}</span>
-                        <span class="pl-4">{{ first_event.begin|date:"l j. E"|capfirst }}</span>
+                        <span class="pr-4">{{ first_event.summary }}</span>
+                        <span class="pl-4">{{ first_event.start|date:"l j. E"|capfirst }}</span>
                       </div>
                     </span>
                   {% endif %}
diff --git a/elections2021/templates/elections2021/elections2021_calendar_page.html b/elections2021/templates/elections2021/elections2021_calendar_page.html
index 25245da7..ca07a3e0 100644
--- a/elections2021/templates/elections2021/elections2021_calendar_page.html
+++ b/elections2021/templates/elections2021/elections2021_calendar_page.html
@@ -171,11 +171,11 @@
       {% if not request.GET.region or request.GET.region == event.pir.region %}
         <div class="grid grid-cols-12 items-center calendar-table-row my-1">
           <div class="col-span-2 head-alt-md calendar-table-row__col" style="color: #92ac00">
-            <span>{{ event.begin|date:"j." }}</span>
+            <span>{{ event.start|date:"j." }}</span>
           </div>
           <div class="col-span-8 grid grid-cols-3 calendar-table-row__col">
             <div class="col-span-3 md:col-span-1">
-              <strong class="block">{{ event.begin|date:"l j. E"|capfirst }}</strong>
+              <strong class="block">{{ event.start|date:"l j. E"|capfirst }}</strong>
               <p class="font-light text-sm mt-1">{{ event.duration }}</p>
             </div>
             <div class="col-span-3 md:col-span-2 mt-4 md:mt-0">
diff --git a/elections2021/templates/elections2021/elections2021_home_page.html b/elections2021/templates/elections2021/elections2021_home_page.html
index 03f5b850..d524ae2c 100644
--- a/elections2021/templates/elections2021/elections2021_home_page.html
+++ b/elections2021/templates/elections2021/elections2021_home_page.html
@@ -114,11 +114,11 @@
           {% for event in page.calendar.current_events %}
           <div class="grid grid-cols-12 items-center my-1">
             <div class="col-span-2 head-alt-md calendar-table-row__col text-acidgreen border-acidgreen">
-              <span>{{ event.begin|date:"j." }}</span>
+              <span>{{ event.start|date:"j." }}</span>
             </div>
             <div class="col-span-8 grid grid-cols-3 calendar-table-row__col text-white border-black">
               <div class="col-span-3 md:col-span-1">
-                <strong class="block">{{ event.begin|date:"l j. E"|capfirst }}</strong>
+                <strong class="block">{{ event.start|date:"l j. E"|capfirst }}</strong>
                 <p class="font-light text-sm mt-1">{{ event.duration }}</p>
               </div>
               <div class="col-span-3 md:col-span-2 mt-4 md:mt-0">
diff --git a/region/templates/region/base.html b/region/templates/region/base.html
index 6ee50a4c..df54a1ac 100644
--- a/region/templates/region/base.html
+++ b/region/templates/region/base.html
@@ -160,8 +160,8 @@
                 {% if first_event %}
                   <span class="btn text-sm max-w-full hidden lg:block" @click="toggleView('calendar')">
                     <div class="btn__body bg-grey-800 text-grey-200 flex divide-x">
-                      <span class="pr-4">{{ first_event.name }}</span>
-                      <span class="pl-4">{{ first_event.begin|date:"l j. E"|capfirst }}</span>
+                      <span class="pr-4">{{ first_event.summary }}</span>
+                      <span class="pl-4">{{ first_event.start|date:"l j. E"|capfirst }}</span>
                     </div>
                   </span>
                 {% endif %}
diff --git a/requirements/base.in b/requirements/base.in
index 4dfa9baf..88cf58c9 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -11,6 +11,7 @@ pirates<=0.7
 whitenoise
 opencv-python
 requests
+icalevnt
 ics
 arrow
 sentry-sdk
diff --git a/requirements/base.txt b/requirements/base.txt
index 07f11e5d..c5f3caf1 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -8,8 +8,6 @@ amqp==5.1.1
     # via kombu
 anyascii==0.3.1
     # via wagtail
-appnope==0.1.3
-    # via ipython
 arrow==0.14.7
     # via
     #   -r base.in
@@ -63,6 +61,8 @@ cryptography==37.0.2
     #   pyopenssl
 cssselect2==0.6.0
     # via weasyprint
+datetime==4.3
+    # via icalevnt
 decorator==5.1.1
     # via ipython
 deprecated==1.2.13
@@ -118,6 +118,12 @@ html5lib==1.1
     # via
     #   wagtail
     #   weasyprint
+httplib2==0.20.1
+    # via icalevnt
+icalendar==4.0.8
+    # via icalevnt
+icalevnt==0.1.26
+    # via -r base.in
 ics==0.7
     # via -r base.in
 idna==3.3
@@ -177,8 +183,10 @@ pygments==2.12.0
     # via ipython
 pyopenssl==22.0.0
     # via josepy
-pyparsing==3.0.8
-    # via packaging
+pyparsing==2.4.7
+    # via
+    #   httplib2
+    #   packaging
 pypdf2==1.27.12
     # via -r base.in
 pyphen==0.12.0
@@ -186,12 +194,17 @@ pyphen==0.12.0
 python-dateutil==2.8.2
     # via
     #   arrow
+    #   icalendar
+    #   icalevnt
     #   ics
-pytz==2022.1
+pytz==2021.3
     # via
     #   celery
+    #   datetime
     #   django-modelcluster
     #   djangorestframework
+    #   icalendar
+    #   icalevnt
     #   l18n
 pyyaml==6.0
     # via -r base.in
@@ -270,6 +283,8 @@ xlsxwriter==3.0.3
     # via wagtail
 xlwt==1.3.0
     # via tablib
+zope-interface==5.4.0
+    # via datetime
 zopfli==0.2.1
     # via fonttools
 
diff --git a/senat_campaign/templates/senat_campaign/calendar_event_snippet.html b/senat_campaign/templates/senat_campaign/calendar_event_snippet.html
index b8cee4a8..92d923b1 100644
--- a/senat_campaign/templates/senat_campaign/calendar_event_snippet.html
+++ b/senat_campaign/templates/senat_campaign/calendar_event_snippet.html
@@ -1,13 +1,13 @@
 <div class="calendar__row">
   <div class="calendar__row__date1">
-    <h3>{{ event.begin|date:"d" }}.</h3>
+    <h3>{{ event.start|date:"d" }}.</h3>
   </div>
   <div class="calendar__row__date2">
-    <h6>{{ event.begin|date:"j.n.Y" }}</h6>
+    <h6>{{ event.start|date:"j.n.Y" }}</h6>
     <p>{{ event.duration }}</p>
   </div>
   <div class="calendar__row__content">
-    <h6>{{ event.name }}</h6>
+    <h6>{{ event.summary }}</h6>
     <p>{{ event.location }}</p>
   </div>
 </div><!-- /calendar__row -->
diff --git a/shared/templates/shared/calendar_current_events_snippet.html b/shared/templates/shared/calendar_current_events_snippet.html
index 0913caaa..0c95f7e7 100644
--- a/shared/templates/shared/calendar_current_events_snippet.html
+++ b/shared/templates/shared/calendar_current_events_snippet.html
@@ -5,6 +5,7 @@
         <i class="ico--calendar banner__icon"></i>
         <div class="banner__body"><h1 class="head-alt-md banner__cta">Kalendář</h1>
           <button class="btn btn--white btn--fullwidth sm:btn--autowidth mt-8">
+            {{ event }}
             <div class="btn__body">Zobrazit další</div>
           </button>
         </div>
@@ -14,15 +15,15 @@
       {% for event in page.root_page.calendar.current_events %}
         <div class="grid grid-cols-12 items-center calendar-table-row">
           <div class="col-span-2 text-orange-300 head-alt-md calendar-table-row__col">
-            <span>{{ event.begin|date:"j." }}</span>
+            <span>{{ event.start|date:"j." }}</span>
           </div>
           <div class="col-span-8 grid grid-cols-3 col-gap-4 calendar-table-row__col calendar-table-row__col--norborder">
             <div class="col-span-3 md:col-span-1">
-              <strong class="block">{{ event.begin|date:"l j. E"|capfirst }}</strong>
+              <strong class="block">{{ event.start|date:"l j. E"|capfirst }}</strong>
               <p class="font-light text-sm mt-1">{{ event.duration }}</p></div>
             <div class="col-span-3 md:col-span-2 mt-4 md:mt-0 overflow-hidden">
               <span class="font-bold block">
-                {{ event.name }}
+                {{ event.summary }}
               </span>
               {% if event.description %}
                 <p class="font-light text-sm mt-1">
@@ -34,8 +35,7 @@
           <div class="col-span-2 text-center font-light calendar-table-row__col">
             {% if event.location and 'jitsi.pirati' in event.location %}
               <a href="{{ event.location }}" class="icon-link">
-                <i aria-hidden="true" class="ico--link text-violet-300 mr-1"></i>
-{#                  <i aria-hidden="true" class="ico--jitsi text-violet-300 mr-1"></i> TODO requires latest styleguide version #}
+                <i aria-hidden="true" class="ico--jitsi text-violet-300 mr-1"></i>
                 <span>Jitsi URL</span>
               </a>
             {% elif event.location %}
diff --git a/shared/templates/shared/small_calendar_snippet.html b/shared/templates/shared/small_calendar_snippet.html
index ab10f212..dcb59eee 100644
--- a/shared/templates/shared/small_calendar_snippet.html
+++ b/shared/templates/shared/small_calendar_snippet.html
@@ -7,15 +7,15 @@
     {% for event in events|default:page.root_page.calendar.current_events %}
       <div class="grid grid-cols-12 items-center calendar-table-row calendar-table-row--standalone">
         <div class="col-span-2 text-orange-300 head-alt-md calendar-table-row__col">
-          <span>{{ event.begin|date:"j." }}</span>
+          <span>{{ event.start|date:"j." }}</span>
         </div>
         <div class="col-span-8 grid grid-cols-3 calendar-table-row__col">
           <div class="col-span-3 md:col-span-1">
-            <strong class="block">{{ event.begin|date:"l j. E"|capfirst }}</strong>
+            <strong class="block">{{ event.start|date:"l j. E"|capfirst }}</strong>
             <p class="font-light text-sm mt-1">{{ event.duration }}</p></div>
           <div class="col-span-3 md:col-span-2 mt-4 md:mt-0">
                 <span class="font-bold block">
-                  {{ event.name }}
+                  {{ event.summary }}
                 </span>
             {% if event.description %}
               <p class="font-light text-sm mt-1">{{ event.description | safe }}</p>
diff --git a/uniweb/templates/uniweb/blocks/calendar_agenda.html b/uniweb/templates/uniweb/blocks/calendar_agenda.html
index ba8f6528..c45f1caf 100644
--- a/uniweb/templates/uniweb/blocks/calendar_agenda.html
+++ b/uniweb/templates/uniweb/blocks/calendar_agenda.html
@@ -2,15 +2,15 @@
   {% for event in events %}
     <div class="grid grid-cols-12 items-center calendar-table-row calendar-table-row--standalone">
       <div class="col-span-2 text-blue-200 head-alt-md calendar-table-row__col">
-        <span>{{ event.begin|date:"j." }}</span>
+        <span>{{ event.start|date:"j." }}</span>
       </div>
       <div class="col-span-8 grid grid-cols-3 calendar-table-row__col">
         <div class="col-span-3 md:col-span-1">
-          <strong class="block">{{ event.begin|date:"l j. E"|capfirst }}</strong>
+          <strong class="block">{{ event.start|date:"l j. E"|capfirst }}</strong>
           <p class="font-light text-sm mt-1">{{ event.duration }}</p>
         </div>
         <div class="col-span-3 md:col-span-2 mt-4 md:mt-0">
-          <strong class="block">{{ event.name }}</strong>
+          <strong class="block">{{ event.summary }}</strong>
           {% if event.description %}
             <p class="font-light text-sm mt-1">{{ event.description }}</p>
           {% endif %}
-- 
GitLab