diff --git a/calendar_utils/icalevents/__init__.py b/calendar_utils/icalevents/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7a6441b622147f81d0c2ca014a3e48a99e53b1eb
--- /dev/null
+++ b/calendar_utils/icalevents/__init__.py
@@ -0,0 +1,5 @@
+"""
+iCalEvents search all events occurring in a given time frame in an iCal file.
+"""
+
+__all__ = ["icaldownload", "icalparser", "icalevents"]
diff --git a/calendar_utils/icalevents/icaldownload.py b/calendar_utils/icalevents/icaldownload.py
new file mode 100644
index 0000000000000000000000000000000000000000..6aadeddb706b011bc414b9d10640a73fa9985e31
--- /dev/null
+++ b/calendar_utils/icalevents/icaldownload.py
@@ -0,0 +1,109 @@
+"""
+Downloads an iCal url or reads an iCal file.
+"""
+from httplib2 import Http
+import logging
+
+
+def apple_data_fix(content):
+    """
+    Fix Apple tzdata bug.
+
+    :param content: content to fix
+    :return: fixed content
+    """
+    return content.replace("TZOFFSETFROM:+5328", "TZOFFSETFROM:+0053")
+
+
+def apple_url_fix(url):
+    """
+    Fix Apple URL.
+
+    :param url: URL to fix
+    :return: fixed URL
+    """
+    if url.startswith("webcal://"):
+        url = url.replace("webcal://", "http://", 1)
+    return url
+
+
+class ICalDownload:
+    """
+    Downloads or reads and decodes iCal sources.
+    """
+
+    def __init__(self, http=None, encoding="utf-8"):
+        # Get logger
+        logger = logging.getLogger()
+
+        # default http connection to use
+        if http is None:
+            try:
+                http = Http(".cache")
+            except (PermissionError, OSError) as e:
+                # Cache disabled if no write permission in working directory
+                logger.warning(
+                    (
+                        "Caching is disabled due to a read-only working directory: {}"
+                    ).format(e)
+                )
+                http = Http()
+
+        self.http = http
+        self.encoding = encoding
+
+    def data_from_url(self, url, apple_fix=False):
+        """
+        Download iCal data from URL.
+
+        :param url: URL to download
+        :param apple_fix: fix Apple bugs (protocol type and tzdata in iCal)
+        :return: decoded (and fixed) iCal data
+        """
+        if apple_fix:
+            url = apple_url_fix(url)
+
+        _, content = self.http.request(url)
+
+        if not content:
+            raise ConnectionError("Could not get data from %s!" % url)
+
+        return self.decode(content, apple_fix=apple_fix)
+
+    def data_from_file(self, file, apple_fix=False):
+        """
+        Read iCal data from file.
+
+        :param file: file to read
+        :param apple_fix: fix wrong Apple tzdata in iCal
+        :return: decoded (and fixed) iCal data
+        """
+        with open(file, mode="rb") as f:
+            content = f.read()
+
+        if not content:
+            raise IOError("File %s is not readable or is empty!" % file)
+
+        return self.decode(content, apple_fix=apple_fix)
+
+    def data_from_string(self, string_content, apple_fix=False):
+        if not string_content:
+            raise IOError("String content is not readable or is empty!")
+
+        return self.decode(string_content, apple_fix=apple_fix)
+
+    def decode(self, content, apple_fix=False):
+        """
+        Decode content using the set charset.
+
+        :param content: content do decode
+        :param apple_fix: fix Apple txdata bug
+        :return: decoded (and fixed) content
+        """
+        content = content.decode(self.encoding)
+        content = content.replace("\r", "")
+
+        if apple_fix:
+            content = apple_data_fix(content)
+
+        return content
diff --git a/calendar_utils/icalevents/icalevents.py b/calendar_utils/icalevents/icalevents.py
new file mode 100644
index 0000000000000000000000000000000000000000..91ac0e2dbac7c24c248b14165012e3279b25bfcc
--- /dev/null
+++ b/calendar_utils/icalevents/icalevents.py
@@ -0,0 +1,171 @@
+from threading import Lock, Thread
+
+from .icalparser import parse_events, Event
+from .icaldownload import ICalDownload
+
+
+# Lock for event data
+event_lock = Lock()
+# Event data
+event_store = {}
+# Threads
+threads = {}
+
+
+def events(
+    url=None,
+    file=None,
+    string_content=None,
+    start=None,
+    end=None,
+    fix_apple=False,
+    http=None,
+    tzinfo=None,
+    sort=None,
+    strict=False,
+) -> list[Event]:
+    """
+    Get all events form the given iCal URL occurring in the given time range.
+
+    :param url: iCal URL
+    :param file: iCal file path
+    :param string_content: iCal content as string
+    :param start: start date (see dateutils.date)
+    :param end: end date (see dateutils.date)
+    :param fix_apple: fix known Apple iCal issues
+    :param tzinfo: return values in specified tz
+    :param sort: sort return values
+    :param strict: return dates, datetimes and datetime with timezones as specified in ical
+    :sort sorts events by start time
+
+    :return events
+    """
+    found_events = []
+
+    content = None
+    ical_download = ICalDownload(http=http)
+
+    if url:
+        content = ical_download.data_from_url(url, apple_fix=fix_apple)
+
+    if not content and file:
+        content = ical_download.data_from_file(file, apple_fix=fix_apple)
+
+    if not content and string_content:
+        content = ical_download.data_from_string(string_content, apple_fix=fix_apple)
+
+    found_events += parse_events(
+        content, start=start, end=end, tzinfo=tzinfo, sort=sort, strict=strict
+    )
+
+    if found_events is not None and sort is True:
+        found_events.sort()
+
+    return found_events
+
+
+def request_data(key, url, file, string_content, start, end, fix_apple):
+    """
+    Request data, update local data cache and remove this Thread from queue.
+
+    :param key: key for data source to get result later
+    :param url: iCal URL
+    :param file: iCal file path
+    :param string_content: iCal content as string
+    :param start: start date
+    :param end: end date
+    :param fix_apple: fix known Apple iCal issues
+    """
+    data = []
+
+    try:
+        data += events(
+            url=url,
+            file=file,
+            string_content=string_content,
+            start=start,
+            end=end,
+            fix_apple=fix_apple,
+        )
+    finally:
+        update_events(key, data)
+        request_finished(key)
+
+
+def events_async(
+    key, url=None, file=None, start=None, string_content=None, end=None, fix_apple=False
+):
+    """
+    Trigger an asynchronous data request.
+
+    :param key: key for data source to get result later
+    :param url: iCal URL
+    :param file: iCal file path
+    :param string_content: iCal content as string
+    :param start: start date
+    :param end: end date
+    :param fix_apple: fix known Apple iCal issues
+    """
+    t = Thread(
+        target=request_data,
+        args=(key, url, file, string_content, start, end, fix_apple),
+    )
+
+    with event_lock:
+        if key not in threads:
+            threads[key] = []
+
+        threads[key].append(t)
+
+        if not threads[key][0].is_alive():
+            threads[key][0].start()
+
+
+def request_finished(key):
+    """
+    Remove finished Thread from queue.
+
+    :param key: data source key
+    """
+    with event_lock:
+        threads[key] = threads[key][1:]
+
+        if threads[key]:
+            threads[key][0].run()
+
+
+def update_events(key, data):
+    """
+    Set the latest events for a key.
+
+    :param key: key to set
+    :param data: events for key
+    """
+    with event_lock:
+        event_store[key] = data
+
+
+def latest_events(key):
+    """
+    Get the latest downloaded events for the given key.
+
+    :return: events for key
+    """
+    with event_lock:
+        # copy data
+        res = event_store[key][:]
+
+    return res
+
+
+def all_done(key):
+    """
+    Check if requests for the given key are active.
+
+    :param key: key for requests
+    :return: True if requests are pending or active
+    """
+    with event_lock:
+        if threads[key]:
+            return False
+        return True
diff --git a/calendar_utils/icalevents/icalparser.py b/calendar_utils/icalevents/icalparser.py
new file mode 100644
index 0000000000000000000000000000000000000000..80161afcea8508954587c62d419ed19ee21c4bc9
--- /dev/null
+++ b/calendar_utils/icalevents/icalparser.py
@@ -0,0 +1,565 @@
+"""
+Parse iCal data to Events.
+"""
+# for UID generation
+from faulthandler import is_enabled
+from random import randint
+from datetime import datetime, timedelta, date, tzinfo
+from typing import Optional
+
+from dateutil.rrule import rrulestr
+from dateutil.tz import UTC, gettz
+
+from icalendar import Calendar
+from icalendar.prop import vDDDLists, vText
+from uuid import uuid4
+
+from icalendar.windows_to_olson import WINDOWS_TO_OLSON
+from pytz import timezone
+
+
+def now():
+    """
+    Get current time.
+
+    :return: now as datetime with timezone
+    """
+    return datetime.now(UTC)
+
+
+class Attendee(str):
+    def __init__(self, address):
+        self.address = address
+
+    def __repr__(self):
+        return self.address.encode("utf-8").decode("ascii")
+
+    @property
+    def params(self):
+        return self.address.params
+
+
+class Event:
+    """
+    Represents one event (occurrence in case of reoccurring events).
+    """
+
+    def __init__(self):
+        """
+        Create a new event occurrence.
+        """
+        self.uid = -1
+        self.summary = None
+        self.description = None
+        self.start = None
+        self.end = None
+        self.all_day = True
+        self.transparent = False
+        self.recurring = False
+        self.location = None
+        self.private = False
+        self.created = None
+        self.last_modified = None
+        self.sequence = None
+        self.recurrence_id = None
+        self.attendee = None
+        self.organizer = None
+        self.categories = None
+        self.floating = None
+        self.status = None
+        self.url = None
+
+    def time_left(self, time=None):
+        """
+        timedelta form now to event.
+
+        :return: timedelta from now
+        """
+        time = time or now()
+        return self.start - time
+
+    def __lt__(self, other):
+        """
+        Events are sorted by start time by default.
+
+        :param other: other event
+        :return: True if start of this event is smaller than other
+        """
+        if not other or not isinstance(other, Event):
+            raise ValueError(
+                "Only events can be compared with each other! Other is %s" % type(other)
+            )
+        else:
+            # start and end can be dates, datetimes and datetimes with timezoneinfo
+            if type(self.start) is date and type(other.start) is date:
+                return self.start < other.start
+            elif type(self.start) is datetime and type(other.start) is datetime:
+                if self.start.tzinfo == other.start.tzinfo:
+                    return self.start < other.start
+                else:
+                    return self.start.astimezone(UTC) < other.start.astimezone(UTC)
+            elif type(self.start) is date and type(other.start) is datetime:
+                return self.start < other.start.date()
+            elif type(self.start) is datetime and type(other.start) is date:
+                return self.start.date() < other.start
+
+    def __str__(self):
+        return "%s: %s (%s)" % (self.start, self.summary, self.end - self.start)
+
+    def astimezone(self, tzinfo):
+
+        if type(self.start) is datetime:
+            self.start = self.start.astimezone(tzinfo)
+
+        if type(self.end) is datetime:
+            self.end = self.end.astimezone(tzinfo)
+
+        return self
+
+    def copy_to(self, new_start=None, uid=None):
+        """
+        Create a new event equal to this with new start date.
+
+        :param new_start: new start date
+        :param uid: UID of new event
+        :return: new event
+        """
+        if not new_start:
+            new_start = self.start
+
+        if not uid:
+            uid = "%s_%d" % (self.uid, randint(0, 1000000))
+
+        ne = Event()
+        ne.summary = self.summary
+        ne.description = self.description
+        ne.start = new_start
+
+        if self.end:
+            duration = self.end - self.start
+            ne.end = new_start + duration
+
+        ne.all_day = self.all_day
+        ne.recurring = self.recurring
+        ne.location = self.location
+        ne.attendee = self.attendee
+        ne.organizer = self.organizer
+        ne.private = self.private
+        ne.transparent = self.transparent
+        ne.uid = uid
+        ne.created = self.created
+        ne.last_modified = self.last_modified
+        ne.categories = self.categories
+        ne.floating = self.floating
+        ne.status = self.status
+        ne.url = self.url
+
+        return ne
+
+
+def encode(value: Optional[vText]) -> Optional[str]:
+    if value is None:
+        return None
+    try:
+        return str(value)
+    except UnicodeEncodeError:
+        return str(value.encode("utf-8"))
+
+
+def create_event(component, utc_default):
+    """
+    Create an event from its iCal representation.
+
+    :param component: iCal component
+    :return: event
+    """
+
+    event = Event()
+
+    event.start = component.get("dtstart").dt
+    # The RFC specifies that the TZID parameter must be specified for datetime or time
+    # Otherwise we set a default timezone (if only one is set with VTIMEZONE) or utc
+    event.floating = type(component.get("dtstart").dt) == date and utc_default
+
+    if component.get("dtend"):
+        event.end = component.get("dtend").dt
+    elif component.get("duration"):  # compute implicit end as start + duration
+        event.end = event.start + component.get("duration").dt
+    else:  # compute implicit end as start + 0
+        event.end = event.start
+
+    event.summary = encode(component.get("summary"))
+    event.description = encode(component.get("description"))
+    event.all_day = type(component.get("dtstart").dt) is date
+    if component.get("rrule"):
+        event.recurring = True
+    event.location = encode(component.get("location"))
+
+    if component.get("attendee"):
+        event.attendee = component.get("attendee")
+        if type(event.attendee) is list:
+            event.attendee = [Attendee(attendee) for attendee in event.attendee]
+        else:
+            event.attendee = Attendee(event.attendee)
+    else:
+        event.attendee = str(None)
+
+    if component.get("uid"):
+        event.uid = component.get("uid").encode("utf-8").decode("ascii")
+    else:
+        event.uid = str(uuid4())  # Be nice - treat every event as unique
+
+    if component.get("organizer"):
+        event.organizer = component.get("organizer").encode("utf-8").decode("ascii")
+    else:
+        event.organizer = str(None)
+
+    if component.get("class"):
+        event_class = component.get("class")
+        event.private = event_class == "PRIVATE" or event_class == "CONFIDENTIAL"
+
+    if component.get("transp"):
+        event.transparent = component.get("transp") == "TRANSPARENT"
+
+    if component.get("created"):
+        event.created = component.get("created").dt
+
+    if component.get("RECURRENCE-ID"):
+        rid = component.get("RECURRENCE-ID").dt
+
+        # Spec defines that if DTSTART is a date RECURRENCE-ID also is to be interpreted as a date
+        if type(component.get("dtstart").dt) is date:
+            event.recurrence_id = date(year=rid.year, month=rid.month, day=rid.day)
+        else:
+            event.recurrence_id = rid
+
+    if component.get("last-modified"):
+        event.last_modified = component.get("last-modified").dt
+    elif event.created:
+        event.last_modified = event.created
+
+    # sequence can be 0 - test for None instead
+    if not component.get("sequence") is None:
+        event.sequence = component.get("sequence")
+
+    if component.get("categories"):
+        categoriesval = component.get("categories")
+        categories = component.get("categories").cats if hasattr(categoriesval, "cats") else categoriesval
+        encoded_categories = list()
+        for category in categories:
+            encoded_categories.append(encode(category))
+        event.categories = encoded_categories
+
+    if component.get("status"):
+        event.status = encode(component.get("status"))
+
+    if component.get("url"):
+        event.url = encode(component.get("url"))
+
+    return event
+
+
+def parse_events(
+    content,
+    start=None,
+    end=None,
+    default_span=timedelta(days=7),
+    tzinfo=None,
+    sort=False,
+    strict=False,
+):
+    """
+    Query the events occurring in a given time range.
+
+    :param content: iCal URL/file content as String
+    :param start: start date for search, default today (in UTC format)
+    :param end: end date for search (in UTC format)
+    :param default_span: default query length (one week)
+    :return: events as list
+    """
+    if not start:
+        start = now()
+
+    if not end:
+        end = start + default_span
+
+    if not content:
+        raise ValueError("Content is invalid!")
+
+    calendar = Calendar.from_ical(content)
+
+    # > Will be deprecated ========================
+    # Calendar.from_ical already parses timezones as specified in the ical.
+    # We can specify a 'default' tz but this is not according to spec.
+    # Kept this here to verify tests and backward compatibility
+
+    # Keep track of the timezones defined in the calendar
+    timezones = {}
+
+    # Parse non standard timezone name
+    if "X-WR-TIMEZONE" in calendar:
+        x_wr_timezone = str(calendar["X-WR-TIMEZONE"])
+        timezones[x_wr_timezone] = get_timezone(x_wr_timezone)
+
+    for c in calendar.walk("VTIMEZONE"):
+        name = str(c["TZID"])
+        try:
+            timezones[name] = c.to_tz()
+        except IndexError:
+            # This happens if the VTIMEZONE doesn't
+            # contain start/end times for daylight
+            # saving time. Get the system pytz
+            # value from the name as a fallback.
+            timezones[name] = timezone(name)
+
+    # If there's exactly one timezone in the file,
+    # assume it applies globally, otherwise UTC
+    utc_default = False
+    if len(timezones) == 1:
+        cal_tz = get_timezone(list(timezones)[0])
+    else:
+        utc_default = True
+        cal_tz = UTC
+    # < ==========================================
+
+    found = []
+
+    def add_if_not_exception(event):
+        exdate = "%04d%02d%02d" % (
+            event.start.year,
+            event.start.month,
+            event.start.day,
+        )
+
+        if exdate not in exceptions:
+            found.append(event)
+
+    for component in calendar.walk():
+        exceptions = {}
+
+        if "EXDATE" in component:
+            # Deal with the fact that sometimes it's a list and
+            # sometimes it's a singleton
+            exlist = []
+            if isinstance(component["EXDATE"], vDDDLists):
+                exlist = component["EXDATE"].dts
+            else:
+                exlist = component["EXDATE"]
+            for ex in exlist:
+                exdate = ex.to_ical().decode("UTF-8")
+                exceptions[exdate[0:8]] = exdate
+
+        if component.name == "VEVENT":
+            e = create_event(component, utc_default)
+
+            # make rule.between happy and provide from, to points in time that have the same format as dtstart
+            s = component["dtstart"].dt
+            if type(s) is date and not e.recurring:
+                f, t = date(start.year, start.month, start.day), date(
+                    end.year, end.month, end.day
+                )
+            elif type(s) is datetime and s.tzinfo:
+                f, t = datetime(
+                    start.year, start.month, start.day, tzinfo=s.tzinfo
+                ), datetime(end.year, end.month, end.day, tzinfo=s.tzinfo)
+            else:
+                f, t = datetime(start.year, start.month, start.day), datetime(
+                    end.year, end.month, end.day
+                )
+
+            if e.recurring:
+                rule = parse_rrule(component)
+                for dt in rule.between(f, t, inc=True):
+                    # Recompute the start time in the current timezone *on* the
+                    # date of *this* occurrence. This handles the case where the
+                    # recurrence has crossed over the daylight savings time boundary.
+                    if type(dt) is datetime and dt.tzinfo:
+                        dtstart = dt.replace(tzinfo=get_timezone(str(dt.tzinfo)))
+                        ecopy = e.copy_to(
+                            dtstart.date() if type(s) is date else dtstart, e.uid
+                        )
+                    else:
+                        ecopy = e.copy_to(dt.date() if type(s) is date else dt, e.uid)
+                    add_if_not_exception(ecopy)
+
+            elif e.end >= f and e.start <= t:
+                add_if_not_exception(e)
+
+    result = found.copy()
+
+    # Remove events that are replaced in ical
+    for event in found:
+        if not event.recurrence_id and (event.uid, event.start) in [
+            (f.uid, f.recurrence_id) for f in found
+        ]:
+            result.remove(event)
+
+    # > Will be deprecated ========================
+    # We will apply default cal_tz as required by some tests.
+    # This is just here for backward-compatibility
+    if not strict:
+        for event in result:
+            if type(event.start) is date:
+                event.start = datetime(
+                    year=event.start.year,
+                    month=event.start.month,
+                    day=event.start.day,
+                    hour=0,
+                    minute=0,
+                    tzinfo=cal_tz,
+                )
+                event.end = datetime(
+                    year=event.end.year,
+                    month=event.end.month,
+                    day=event.end.day,
+                    hour=0,
+                    minute=0,
+                    tzinfo=cal_tz,
+                )
+            elif type(event.start) is datetime:
+                if event.start.tzinfo:
+                    event.start = event.start.astimezone(cal_tz)
+                    event.end = event.end.astimezone(cal_tz)
+                else:
+                    event.start = event.start.replace(tzinfo=cal_tz)
+                    event.end = event.end.replace(tzinfo=cal_tz)
+
+            if event.created:
+                if type(event.created) is date:
+                    event.created = datetime(
+                        year=event.created.year,
+                        month=event.created.month,
+                        day=event.created.day,
+                        hour=0,
+                        minute=0,
+                        tzinfo=cal_tz,
+                    )
+                if type(event.created) is datetime:
+                    if event.created.tzinfo:
+                        event.created = event.created.astimezone(cal_tz)
+                    else:
+                        event.created = event.created.replace(tzinfo=cal_tz)
+
+            if event.last_modified:
+                if type(event.last_modified) is date:
+                    event.last_modified = datetime(
+                        year=event.last_modified.year,
+                        month=event.last_modified.month,
+                        day=event.last_modified.day,
+                        hour=0,
+                        minute=0,
+                        tzinfo=cal_tz,
+                    )
+                if type(event.last_modified) is datetime:
+                    if event.last_modified.tzinfo:
+                        event.last_modified = event.last_modified.astimezone(cal_tz)
+                    else:
+                        event.last_modified = event.last_modified.replace(tzinfo=cal_tz)
+    # < ==========================================
+
+    if sort:
+        result.sort()
+
+    if tzinfo:
+        result = [event.astimezone(tzinfo) for event in result]
+
+    return result
+
+
+def parse_rrule(component):
+    """
+    Extract a dateutil.rrule object from an icalendar component. Also includes
+    the component's dtstart and exdate properties. The rdate and exrule
+    properties are not yet supported.
+
+    :param component: icalendar component
+    :return: extracted rrule or rruleset or None
+    """
+
+    dtstart = component.get("dtstart").dt
+
+    # component['rrule'] can be both a scalar and a list
+    rrules = component.get("rrule")
+    if not isinstance(rrules, list):
+        rrules = [rrules]
+
+    def conform_until(until, dtstart):
+        if type(dtstart) is datetime:
+            # If we have timezone defined adjust for daylight saving time
+            if type(until) is datetime:
+                return until + abs(
+                    (
+                        until.astimezone(dtstart.tzinfo).utcoffset()
+                        if until.tzinfo is not None and dtstart.tzinfo is not None
+                        else None
+                    )
+                    or timedelta()
+                )
+
+            return (
+                until.astimezone(UTC)
+                if type(until) is datetime
+                else datetime(
+                    year=until.year, month=until.month, day=until.day, tzinfo=UTC
+                )
+            ) + (
+                (dtstart.tzinfo.utcoffset(dtstart) if dtstart.tzinfo else None)
+                or timedelta()
+            )
+
+        return until.date() + timedelta(days=1) if type(until) is datetime else until
+
+    for index, rru in enumerate(rrules):
+        if "UNTIL" in rru:
+            rrules[index]["UNTIL"] = [
+                conform_until(until, dtstart) for until in rrules[index]["UNTIL"]
+            ]
+
+    rule = rrulestr(
+        "\n".join(x.to_ical().decode() for x in rrules),
+        dtstart=dtstart,
+        forceset=True,
+        unfold=True,
+    )
+
+    if component.get("exdate"):
+        # Add exdates to the rruleset
+        for exd in extract_exdates(component):
+            if type(dtstart) is date:
+                rule.exdate(exd.replace(tzinfo=None))
+            else:
+                rule.exdate(exd)
+
+    # TODO: What about rdates and exrules?
+    if component.get("exrule"):
+        pass
+
+    if component.get("rdate"):
+        pass
+
+    return rule
+
+
+def extract_exdates(component):
+    """
+    Compile a list of all exception dates stored with a component.
+
+    :param component: icalendar iCal component
+    :return: list of exception dates
+    """
+    dates = []
+    exd_prop = component.get("exdate")
+    if isinstance(exd_prop, list):
+        for exd_list in exd_prop:
+            dates.extend(exd.dt for exd in exd_list.dts)
+    else:  # it must be a vDDDLists
+        dates.extend(exd.dt for exd in exd_prop.dts)
+
+    return dates
+
+
+def get_timezone(tz_name):
+    if tz_name in WINDOWS_TO_OLSON:
+        return gettz(WINDOWS_TO_OLSON[tz_name])
+    else:
+        return gettz(tz_name)
diff --git a/requirements/base.in b/requirements/base.in
index caecafea0775aebba0cef5cd45879aa7bb0da55f..0dde894005a1b57b1d6821db1e8912c4119d6213 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -13,7 +13,6 @@ pirates<=0.7
 whitenoise==5.3.0
 opencv-python
 requests
-icalevents
 ics
 arrow
 sentry-sdk
diff --git a/requirements/base.txt b/requirements/base.txt
index 29d9504c7d87a20a1f3d48f3b32f606522d7e7be..1ea97ca2a4ebaaf91048b6247eed88ffafb4b72d 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -1,57 +1,53 @@
 #
-# This file is autogenerated by pip-compile with Python 3.10
+# This file is autogenerated by pip-compile with Python 3.11
 # by the following command:
 #
 #    pip-compile base.in
 #
-amqp==5.1.1
+amqp==5.2.0
     # via kombu
 anyascii==0.3.2
     # via wagtail
-appnope==0.1.3
-    # via ipython
-arrow==1.2.3
+arrow==1.3.0
     # via
     #   -r base.in
     #   ics
 asgiref==3.7.2
     # via django
-asttokens==2.2.1
+asttokens==2.4.1
     # via stack-data
-async-timeout==4.0.2
+async-timeout==4.0.3
     # via redis
-attrs==23.1.0
+attrs==23.2.0
     # via
     #   cattrs
     #   ics
     #   requests-cache
-backcall==0.2.0
-    # via ipython
 beautifulsoup4==4.11.2
     # via
     #   -r base.in
     #   wagtail
-billiard==4.1.0
+billiard==4.2.0
     # via celery
-bleach==6.0.0
+bleach==6.1.0
     # via -r base.in
-brotli==1.0.9
+brotli==1.1.0
     # via fonttools
-cattrs==23.1.2
+cattrs==23.2.3
     # via requests-cache
-celery==5.3.1
+celery==5.3.6
     # via -r base.in
-certifi==2023.5.7
+certifi==2024.2.2
     # via
     #   requests
     #   sentry-sdk
-cffi==1.15.1
+cffi==1.16.0
     # via
     #   cryptography
     #   weasyprint
-charset-normalizer==3.2.0
+charset-normalizer==3.3.2
     # via requests
-click==8.1.4
+click==8.1.7
     # via
     #   celery
     #   click-didyoumean
@@ -63,18 +59,16 @@ click-plugins==1.1.1
     # via celery
 click-repl==0.3.0
     # via celery
-cryptography==41.0.2
+cryptography==42.0.4
     # via
     #   josepy
     #   mozilla-django-oidc
     #   pyopenssl
 cssselect2==0.7.0
     # via weasyprint
-datetime==4.9
-    # via icalevents
 decorator==5.1.1
     # via ipython
-django==4.1.10
+django==4.1.13
     # via
     #   -r base.in
     #   django-extensions
@@ -96,23 +90,23 @@ django-extensions==3.2.3
     # via -r base.in
 django-filter==22.1
     # via wagtail
-django-modelcluster==6.0
+django-modelcluster==6.2.1
     # via wagtail
 django-permissionedforms==0.1
     # via wagtail
 django-ranged-response==0.2.0
     # via django-simple-captcha
-django-redis==5.3.0
+django-redis==5.4.0
     # via -r base.in
 django-settings-export==1.2.1
     # via -r base.in
-django-simple-captcha==0.5.18
+django-simple-captcha==0.5.20
     # via -r base.in
 django-taggit==3.1.0
     # via wagtail
-django-treebeard==4.7
+django-treebeard==4.7.1
     # via wagtail
-django-widget-tweaks==1.4.12
+django-widget-tweaks==1.5.0
     # via -r base.in
 djangorestframework==3.14.0
     # via wagtail
@@ -120,74 +114,64 @@ draftjs-exporter==2.1.7
     # via wagtail
 et-xmlfile==1.1.0
     # via openpyxl
-exceptiongroup==1.1.2
-    # via cattrs
-executing==1.2.0
+executing==2.0.1
     # via stack-data
-fastjsonschema==2.17.1
+fastjsonschema==2.19.1
     # via -r base.in
-fonttools[woff]==4.40.0
+fonttools[woff]==4.49.0
     # via weasyprint
 html5lib==1.1
     # via
     #   wagtail
     #   weasyprint
-httplib2==0.20.4
-    # via icalevents
-icalendar==4.0.9
-    # via icalevents
-icalevents==0.1.27
-    # via -r base.in
 ics==0.7.2
     # via -r base.in
-idna==3.4
+idna==3.6
     # via requests
-ipython==8.14.0
+ipython==8.21.0
     # via -r base.in
-jedi==0.18.2
+jedi==0.19.1
     # via ipython
-josepy==1.13.0
+josepy==1.14.0
     # via mozilla-django-oidc
-kombu==5.3.1
+kombu==5.3.5
     # via celery
 l18n==2021.3
     # via wagtail
-markdown==3.4.3
+markdown==3.5.2
     # via -r base.in
 matplotlib-inline==0.1.6
     # via ipython
-mozilla-django-oidc==2.0.0
+mozilla-django-oidc==3.0.0
     # via pirates
-numpy==1.25.1
+numpy==1.26.4
     # via opencv-python
 oauthlib==3.2.2
     # via
     #   requests-oauthlib
     #   tweepy
-opencv-python==4.8.0.74
+opencv-python==4.9.0.80
     # via -r base.in
 openpyxl==3.1.2
     # via wagtail
 parso==0.8.3
     # via jedi
-pexpect==4.8.0
-    # via ipython
-pickleshare==0.7.5
+pexpect==4.9.0
     # via ipython
 pillow==9.5.0
     # via
     #   django-simple-captcha
     #   wagtail
     #   weasyprint
-pirates==0.6.0
+pirates==0.7.0
     # via -r base.in
-platformdirs==3.8.1
+platformdirs==4.2.0
     # via requests-cache
-prompt-toolkit==3.0.39
+prompt-toolkit==3.0.43
     # via
     #   click-repl
     #   ipython
-psycopg2-binary==2.9.6
+psycopg2-binary==2.9.9
     # via -r base.in
 ptyprocess==0.7.0
     # via pexpect
@@ -195,14 +179,12 @@ pure-eval==0.2.2
     # via stack-data
 pycparser==2.21
     # via cffi
-pydyf==0.7.0
+pydyf==0.8.0
     # via weasyprint
-pygments==2.15.1
+pygments==2.17.2
     # via ipython
-pyopenssl==23.2.0
+pyopenssl==24.0.0
     # via josepy
-pyparsing==3.1.0
-    # via httplib2
 pypdf2==3.0.1
     # via -r base.in
 pyphen==0.14.0
@@ -211,20 +193,15 @@ python-dateutil==2.8.2
     # via
     #   arrow
     #   celery
-    #   icalendar
-    #   icalevents
     #   ics
-pytz==2021.3
+pytz==2024.1
     # via
-    #   datetime
     #   django-modelcluster
     #   djangorestframework
-    #   icalendar
-    #   icalevents
     #   l18n
-pyyaml==6.0
+pyyaml==6.0.1
     # via -r base.in
-redis==4.6.0
+redis==5.0.1
     # via django-redis
 requests==2.31.0
     # via
@@ -234,11 +211,11 @@ requests==2.31.0
     #   requests-oauthlib
     #   tweepy
     #   wagtail
-requests-cache==1.1.0
+requests-cache==1.2.0
     # via -r base.in
 requests-oauthlib==1.3.1
     # via tweepy
-sentry-sdk==1.28.0
+sentry-sdk==1.40.5
     # via -r base.in
 six==1.16.0
     # via
@@ -249,13 +226,13 @@ six==1.16.0
     #   l18n
     #   python-dateutil
     #   url-normalize
-soupsieve==2.4.1
+soupsieve==2.5
     # via beautifulsoup4
 sqlparse==0.4.4
     # via django
-stack-data==0.6.2
+stack-data==0.6.3
     # via ipython
-tatsu==5.8.3
+tatsu==5.11.3
     # via ics
 telepath==0.3.1
     # via wagtail
@@ -263,26 +240,24 @@ tinycss2==1.2.1
     # via
     #   cssselect2
     #   weasyprint
-traitlets==5.9.0
+traitlets==5.14.1
     # via
     #   ipython
     #   matplotlib-inline
 tweepy==4.14.0
     # via -r base.in
-typing-extensions==4.7.1
-    # via
-    #   asgiref
-    #   cattrs
-tzdata==2023.3
+types-python-dateutil==2.8.19.20240106
+    # via arrow
+tzdata==2024.1
     # via celery
 url-normalize==1.4.3
     # via requests-cache
-urllib3==2.0.3
+urllib3==2.2.1
     # via
     #   requests
     #   requests-cache
     #   sentry-sdk
-vine==5.0.0
+vine==5.1.0
     # via
     #   amqp
     #   celery
@@ -294,13 +269,13 @@ wagtail==4.2.4
     #   wagtail-trash
 wagtail-metadata==4.0.3
     # via -r base.in
-wagtail-trash==1.0.1
+wagtail-trash==2.0.0
     # via -r base.in
 wand==0.6.13
     # via -r base.in
-wcwidth==0.2.6
+wcwidth==0.2.13
     # via prompt-toolkit
-weasyprint==59.0
+weasyprint==61.0
     # via -r base.in
 webencodings==0.5.1
     # via
@@ -312,10 +287,5 @@ whitenoise==5.3.0
     # via -r base.in
 willow==1.4.1
     # via wagtail
-zope-interface==6.0
-    # via datetime
-zopfli==0.2.2
+zopfli==0.2.3
     # via fonttools
-
-# The following packages are considered to be unsafe in a requirements file:
-# setuptools
diff --git a/requirements/dev.txt b/requirements/dev.txt
index bc904efd9d351aecc3d3fd8a1f0503b53af4aa7b..c4562fce41c3f6512ab53e72615cffe5af54639b 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -1,40 +1,38 @@
 #
-# This file is autogenerated by pip-compile with Python 3.10
+# This file is autogenerated by pip-compile with Python 3.11
 # by the following command:
 #
 #    pip-compile dev.in
 #
 asgiref==3.7.2
     # via django
-coverage[toml]==7.2.7
+coverage[toml]==7.4.2
     # via pytest-cov
-django==4.1.10
+django==4.1.13
     # via
     #   -r dev.in
     #   django-debug-toolbar
-django-debug-toolbar==4.1.0
+django-debug-toolbar==4.3.0
     # via -r dev.in
-exceptiongroup==1.1.2
-    # via pytest
-factory-boy==3.2.1
+factory-boy==3.3.0
     # via pytest-factoryboy
-faker==18.13.0
+faker==23.2.1
     # via factory-boy
 fastdiff==0.3.0
     # via snapshottest
-freezegun==1.2.2
+freezegun==1.4.0
     # via pytest-freezegun
 inflection==0.5.1
     # via pytest-factoryboy
 iniconfig==2.0.0
     # via pytest
-packaging==23.1
+packaging==23.2
     # via
     #   pytest
     #   pytest-sugar
-pluggy==1.2.0
+pluggy==1.4.0
     # via pytest
-pytest==7.4.0
+pytest==8.0.1
     # via
     #   -r dev.in
     #   pytest-cov
@@ -45,15 +43,15 @@ pytest==7.4.0
     #   pytest-sugar
 pytest-cov==4.1.0
     # via -r dev.in
-pytest-django==4.5.2
+pytest-django==4.8.0
     # via -r dev.in
-pytest-factoryboy==2.5.1
+pytest-factoryboy==2.6.0
     # via -r dev.in
 pytest-freezegun==0.4.2
     # via -r dev.in
-pytest-mock==3.11.1
+pytest-mock==3.12.0
     # via -r dev.in
-pytest-sugar==0.9.7
+pytest-sugar==1.0.0
     # via -r dev.in
 python-dateutil==2.8.2
     # via
@@ -69,18 +67,12 @@ sqlparse==0.4.4
     # via
     #   django
     #   django-debug-toolbar
-termcolor==2.3.0
+termcolor==2.4.0
     # via
     #   pytest-sugar
     #   snapshottest
-tomli==2.0.1
-    # via
-    #   coverage
-    #   pytest
-typing-extensions==4.7.1
-    # via
-    #   asgiref
-    #   pytest-factoryboy
+typing-extensions==4.9.0
+    # via pytest-factoryboy
 wasmer==1.1.0
     # via fastdiff
 wasmer-compiler-cranelift==1.1.0
diff --git a/requirements/production.txt b/requirements/production.txt
index 4f1ff713e8bdd33fadf6ca9bece8acbfc4575046..74278924de31427cd843cb614e39cdeb8a461afd 100644
--- a/requirements/production.txt
+++ b/requirements/production.txt
@@ -1,11 +1,10 @@
 #
-# This file is autogenerated by pip-compile with Python 3.10
+# This file is autogenerated by pip-compile with Python 3.11
 # by the following command:
 #
 #    pip-compile production.in
 #
-gunicorn==20.1.0
+gunicorn==21.2.0
     # via -r production.in
-
-# The following packages are considered to be unsafe in a requirements file:
-# setuptools
+packaging==23.2
+    # via gunicorn