diff --git a/.isort.cfg b/.isort.cfg
index 444fbf680ab7878dfe5970e09b36e8e9bb812360..1c35b69a72a98586a6b6d0ba165c5690f2d1ea5c 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -4,4 +4,4 @@ line_length = 88
 multi_line_output = 3
 default_sectiont = "THIRDPARTY"
 include_trailing_comma = true
-known_third_party = django,environ,pirates,wagtail
+known_third_party = arrow,django,environ,faker,ics,pirates,pytest,pytz,requests,snapshottest,wagtail
diff --git a/README.md b/README.md
index 9322a7fc317779fd6e5cd8b7f72169a086590b52..ec937158fe6c6d0a87e523e94c756b5102b72081 100644
--- a/README.md
+++ b/README.md
@@ -26,9 +26,11 @@ jako přehled pluginů a rozšíření pro Wagtail.
 
     .
     ├── home            = app na web úvodní stránky Majáku
+    ├── senat_campaign  = app na weby kandidátů na senátory
+    ...
     ├── majak           = Django projekt s konfigurací Majáku
+    ├── calendar_utils  = app s modelem a utilitami na iCal kalendáře
     ├── search          = app pro fulltext search (default, asi se k ničemu nepoužívá)
-    ├── senat_campaign  = app na weby kandidátů na senátory
     └── users           = app s custom user modelem a SSO, apod.
 
 Appky v sobě mají modely pro stránky a statické soubory a templaty. Momentálně se
@@ -55,6 +57,14 @@ V produkci musí být navíc nastaveno:
 | `DJANGO_SECRET_KEY` | | tajný šifrovací klíč |
 | `DJANGO_ALLOWED_HOSTS` | | allowed hosts (více hodnot odděleno čárkami) |
 
+### Management commands
+
+Přes CRON je třeba na pozadí spouštět Django `manage.py` commandy:
+
+* `clearsessions` - maže expirované sessions (denně až týdně)
+* `publish_scheduled_pages` - publikuje naplánované stránky (každou hodinu)
+* `update_callendars` - stáhne a aktualizuje kalendáře (několikrát denně)
+
 ### Přidání nového webu
 
 Doména či subdoména se musí nakonfigurovat v:
diff --git a/calendar_utils/__init__.py b/calendar_utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/calendar_utils/apps.py b/calendar_utils/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..bf508d1a3221c12fa8a38f245727aa2978772b09
--- /dev/null
+++ b/calendar_utils/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class CalendarUtilsConfig(AppConfig):
+    name = "calendar utils"
diff --git a/calendar_utils/management/__init__.py b/calendar_utils/management/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/calendar_utils/management/commands/__init__.py b/calendar_utils/management/commands/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/calendar_utils/management/commands/update_callendars.py b/calendar_utils/management/commands/update_callendars.py
new file mode 100644
index 0000000000000000000000000000000000000000..facfef933a50733951e15b4589ce3637e2d6ee14
--- /dev/null
+++ b/calendar_utils/management/commands/update_callendars.py
@@ -0,0 +1,12 @@
+from django.core.management.base import BaseCommand
+
+from ...models import Calendar
+
+
+class Command(BaseCommand):
+    def handle(self, *args, **options):
+        self.stdout.write("Updating calendars...")
+        for cal in Calendar.objects.all():
+            self.stdout.write(f"+ {cal.url}")
+            cal.update_source()
+        self.stdout.write("Updating calendars finished!")
diff --git a/calendar_utils/migrations/0001_initial.py b/calendar_utils/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..11e59387218badc4efddc33074c3410229886fee
--- /dev/null
+++ b/calendar_utils/migrations/0001_initial.py
@@ -0,0 +1,47 @@
+# Generated by Django 3.0.6 on 2020-05-22 08:30
+
+import django.core.serializers.json
+from django.db import migrations, models
+
+import calendar_utils.models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = []
+
+    operations = [
+        migrations.CreateModel(
+            name="Calendar",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("url", models.URLField(unique=True)),
+                ("source", models.TextField(null=True)),
+                ("last_update", models.DateTimeField(null=True)),
+                (
+                    "past_events",
+                    calendar_utils.models.EventsJSONField(
+                        encoder=django.core.serializers.json.DjangoJSONEncoder,
+                        null=True,
+                    ),
+                ),
+                (
+                    "future_events",
+                    calendar_utils.models.EventsJSONField(
+                        encoder=django.core.serializers.json.DjangoJSONEncoder,
+                        null=True,
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/calendar_utils/migrations/__init__.py b/calendar_utils/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/calendar_utils/models.py b/calendar_utils/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b3f4abf15dffcb62388505cc32393407cba7142
--- /dev/null
+++ b/calendar_utils/models.py
@@ -0,0 +1,45 @@
+import arrow
+import requests
+from django.contrib.postgres.fields import JSONField
+from django.core.serializers.json import DjangoJSONEncoder
+from django.db import models
+
+from .parser import parse_ical, split_events
+
+
+def _convert_arrow_to_datetime(event):
+    event["begin"] = event["begin"].datetime
+    event["end"] = event["end"].datetime
+    return event
+
+
+class EventsJSONField(JSONField):
+    """
+    JSONField for lists of events which converts `begin` and `end` to datetime
+    on load from DB.
+    """
+
+    def from_db_value(self, value, expression, connection):
+        if value:
+            for event in value:
+                event["begin"] = arrow.get(event["begin"]).datetime
+                event["end"] = arrow.get(event["end"]).datetime
+        return value
+
+
+class Calendar(models.Model):
+    url = models.URLField(unique=True)
+    source = models.TextField(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 = split_events(parse_ical(source))
+            self.past_events = list(map(_convert_arrow_to_datetime, past))
+            self.future_events = list(map(_convert_arrow_to_datetime, future))
+        self.last_update = arrow.utcnow().datetime
+        self.save()
diff --git a/calendar_utils/parser.py b/calendar_utils/parser.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe2fff3e793af1e53ce45a6621e3455bf71af295
--- /dev/null
+++ b/calendar_utils/parser.py
@@ -0,0 +1,23 @@
+from operator import attrgetter
+
+import arrow
+from ics import Calendar
+
+EVENT_KEYS = ("begin", "end", "all_day", "name", "description", "location")
+
+
+def parse_ical(source):
+    """Parses iCalendar source and returns events as list of dicts"""
+    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):
+    """Splits events and returns list of past events and future events"""
+    now = arrow.utcnow()
+    past = [ev for ev in events if ev["begin"] < now]
+    future = [ev for ev in events if ev["begin"] > now]
+    return past, future
diff --git a/majak/settings/base.py b/majak/settings/base.py
index 34391bdf3cb980f0511fcd1bdb7972b31255b912..4b8f1fec921b948ff825b657a76e93f48ffd26e4 100644
--- a/majak/settings/base.py
+++ b/majak/settings/base.py
@@ -33,6 +33,7 @@ DATABASES["default"]["ATOMIC_REQUESTS"] = True
 INSTALLED_APPS = [
     "senat_campaign",
     "home",
+    "calendar_utils",
     "users",
     "pirates",
     "search",
diff --git a/pytest.ini b/pytest.ini
index c9a5798015bb1749a83088367188779e861ab9ab..d56649df501e8f881900af8bf5833bbbcc64ffa7 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,3 +1,3 @@
 [pytest]
-addopts = --ds=majak.settings.dev
+addopts = --ds=majak.settings.dev -p no:warnings --nomigrations
 python_files = test_*.py
diff --git a/requirements/base.in b/requirements/base.in
index 5bb5818a2dc0f294e38198ee065fa950d862e36c..902b800aeb86fa4941dca0fb705509aa2914b3b4 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -6,3 +6,6 @@ psycopg2-binary
 pirates<=0.4
 whitenoise
 opencv-python
+requests
+ics
+arrow
diff --git a/requirements/base.txt b/requirements/base.txt
index b0220791641e1e4a29c8df30656c0c39310a827c..377af2d3d0c9f4aec520ea8e602744e9a98f84b6 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -4,6 +4,7 @@
 #
 #    pip-compile base.in
 #
+arrow==0.14.7             # via -r base.in, ics
 asgiref==3.2.7            # via django
 beautifulsoup4==4.8.2     # via wagtail
 certifi==2020.4.5.1       # via requests
@@ -20,6 +21,7 @@ django==3.0.6             # via django-taggit, django-treebeard, djangorestframe
 djangorestframework==3.11.0  # via wagtail
 draftjs-exporter==2.1.7   # via wagtail
 html5lib==1.0.1           # via wagtail
+ics==0.7                  # via -r base.in
 idna==2.9                 # via requests
 josepy==1.3.0             # via mozilla-django-oidc
 l18n==2018.5              # via wagtail
@@ -33,12 +35,14 @@ pyasn1-modules==0.2.8     # via python-ldap
 pyasn1==0.4.8             # via pyasn1-modules, python-ldap
 pycparser==2.20           # via cffi
 pyopenssl==19.1.0         # via josepy
+python-dateutil==2.8.1    # via arrow, ics
 python-ldap==3.2.0        # via pirates
 pytz==2020.1              # via django, django-modelcluster, l18n
-requests==2.23.0          # via mozilla-django-oidc, wagtail
-six==1.14.0               # via cryptography, django-extensions, html5lib, josepy, l18n, mozilla-django-oidc, pyopenssl
+requests==2.23.0          # via -r base.in, mozilla-django-oidc, wagtail
+six==1.14.0               # via cryptography, django-extensions, html5lib, ics, josepy, l18n, mozilla-django-oidc, pyopenssl, python-dateutil
 soupsieve==2.0            # via beautifulsoup4
 sqlparse==0.3.1           # via django
+tatsu==5.5.0              # via ics
 unidecode==1.1.1          # via wagtail
 urllib3==1.25.9           # via requests
 wagtail==2.9              # via -r base.in
diff --git a/requirements/dev.in b/requirements/dev.in
index bcccdaa4bd9ff4f4411120ff969a1c5ac9769a7d..0ffbf3d4fd22f9bc592e39fe912feaac6d2f19db 100644
--- a/requirements/dev.in
+++ b/requirements/dev.in
@@ -4,4 +4,5 @@ pytest-factoryboy
 pytest-cov
 pytest-django
 pytest-freezegun
+pytest-mock
 snapshottest
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 1bc61d2562e2ed08efea1ccd3683ab98f5983ae2..b20c32aaa83179e999228beafd5c120b1b2bab46 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -20,8 +20,9 @@ pytest-cov==2.8.1         # via -r dev.in
 pytest-django==3.9.0      # via -r dev.in
 pytest-factoryboy==2.0.3  # via -r dev.in
 pytest-freezegun==0.4.1   # via -r dev.in
+pytest-mock==3.1.0        # via -r dev.in
 pytest-sugar==0.9.3       # via -r dev.in
-pytest==5.4.2             # via -r dev.in, pytest-cov, pytest-django, pytest-factoryboy, pytest-freezegun, pytest-sugar
+pytest==5.4.2             # via -r dev.in, pytest-cov, pytest-django, pytest-factoryboy, pytest-freezegun, pytest-mock, pytest-sugar
 python-dateutil==2.8.1    # via faker, freezegun
 six==1.14.0               # via freezegun, packaging, python-dateutil, snapshottest
 snapshottest==0.5.1       # via -r dev.in
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/calendar_utils/__init__.py b/tests/calendar_utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/calendar_utils/conftest.py b/tests/calendar_utils/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..736f38060ae6167881bc105562e4a609d52dc5e8
--- /dev/null
+++ b/tests/calendar_utils/conftest.py
@@ -0,0 +1,8 @@
+from pathlib import Path
+
+import pytest
+
+
+@pytest.fixture(scope="session")
+def sample():
+    return (Path(__file__).parent / "sample.ics").read_text()
diff --git a/tests/calendar_utils/sample.ics b/tests/calendar_utils/sample.ics
new file mode 100644
index 0000000000000000000000000000000000000000..895c4856917c5b6b69bc73c14da82de3aab37999
--- /dev/null
+++ b/tests/calendar_utils/sample.ics
@@ -0,0 +1,215 @@
+BEGIN:VCALENDAR
+PRODID:-//Google Inc//Google Calendar 70.9054//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+X-WR-CALNAME:Piráti XOLK
+X-WR-TIMEZONE:Europe/Prague
+X-WR-CALDESC:Kalendář Pirátů olomouckého krajského sdružení.\nVíce zde: htt
+ ps://olomoucky.pirati.cz\nnebo na našem facebooku: https://www.facebook.com
+ /piratiOlomoucko/
+BEGIN:VTIMEZONE
+TZID:Europe/Prague
+X-LIC-LOCATION:Europe/Prague
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=Europe/Prague:20200107T180000
+DTEND;TZID=Europe/Prague:20200107T210000
+RRULE:FREQ=MONTHLY;BYDAY=1TU
+DTSTAMP:20200519T130457Z
+UID:nmrttnjrbd0bjsmj4oabog7g46@google.com
+CREATED:20180926T191919Z
+DESCRIPTION:
+LAST-MODIFIED:20200505T161640Z
+LOCATION:PiCOlo\, 8. května 522/5\, 779 00 Olomouc\, Česko
+SEQUENCE:2
+STATUS:CONFIRMED
+SUMMARY:Veřejná schůze Pirátů Olomouckého kraje
+TRANSP:OPAQUE
+X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-APPLE-MAPKIT-HANDLE=CAESswEaEgkf60w
+ rNMxIQBEtRW0jpz8xQCJaChHEjGVza8OhIHJlcHVibGlrYRICQ1oaCk9sb21vdWNrw70qB09sb2
+ 1vdWMyB09sb21vdWM6Bjc3OSAwMFIKOC4ga3bEm3RuYVoBOGIMOC4ga3bEm3RuYSA4Kgw4LiBrd
+ sSbdG5hIDgyDDguIGt2xJt0bmEgODIONzc5IDAwIE9sb21vdWMyEcSMZXNrw6EgcmVwdWJsaWth
+ ODlAAQ==;X-APPLE-RADIUS=70.58737122558885;X-APPLE-REFERENCEFRAME=1;X-TITLE=
+ "PiCOlo, 8. května 522/5, 779 00 Olomouc, Česko":geo:49.595342,17.248644
+X-APPLE-TRAVEL-ADVISORY-BEHAVIOR;ACKNOWLEDGED=20200505T161640Z:AUTOMATIC
+BEGIN:VALARM
+ACTION:NONE
+TRIGGER;VALUE=DATE-TIME:19760401T005545Z
+X-WR-ALARMUID:298355D7-4042-4B14-9109-5936F652F4AA
+UID:298355D7-4042-4B14-9109-5936F652F4AA
+ACKNOWLEDGED:20200505T161640Z
+X-APPLE-DEFAULT-ALARM:TRUE
+END:VALARM
+END:VEVENT
+BEGIN:VEVENT
+DTSTART:20200408T160000Z
+DTEND:20200408T173000Z
+DTSTAMP:20200519T130457Z
+UID:6e2plsoro6fu52ufhd7iq5fpd1@google.com
+CREATED:20200308T175153Z
+DESCRIPTION:
+LAST-MODIFIED:20200308T175206Z
+LOCATION:Picolo - Pirátské centrum Olomouc\, 8. května 522/5\, 779 00 Olomo
+ uc\, Česko
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Setkání s místopředsedou Evropského parlamentu Marcelem Kolalokou
+TRANSP:OPAQUE
+END:VEVENT
+BEGIN:VEVENT
+DTSTART:20200301T140000Z
+DTEND:20200301T170000Z
+DTSTAMP:20200519T130457Z
+UID:2e3hpgkhl60mmne8012hghfnfp@google.com
+CREATED:20200226T180446Z
+DESCRIPTION:Tato událost má videohovor.\nPřipojit se: https://meet.google.c
+ om/ahv-nrmw-kmp\n+1 484-696-1205 PIN: 546973991#
+LAST-MODIFIED:20200226T192157Z
+LOCATION:Koliba & Pivovar U Tří králů\, Finská 4592/8\, 796 01 Prostějov\,
+ Česko
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Veřejná schůze Prostějovských Pirátů 01.03.2020
+TRANSP:OPAQUE
+END:VEVENT
+BEGIN:VEVENT
+DTSTART:20200221T173000Z
+DTEND:20200221T210000Z
+DTSTAMP:20200519T130457Z
+UID:3o7vh5n89qlt79qorsfsbsmnjm@google.com
+CREATED:20200120T165424Z
+DESCRIPTION:Tradiční páteční deskohraní s Karlem Bezejmeným. K zapůjčení bud
+ ou deskové hry všeho druhu – a to jak jednoduché\, tak náročnější pro ty\,
+ kteří se nebojí u hry strávit delší dobu. Všechny pravidla Vám budou vysvět
+ lena. Nemusíte se bát.
+LAST-MODIFIED:20200219T172956Z
+LOCATION:Picolo - Pirátské centrum Olomouc\, 8. května 522/5\, 779 00 Olomo
+ uc\, Česko
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Páteční deskovky v Picolu - Karel Bezejmenný
+TRANSP:OPAQUE
+X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-APPLE-MAPKIT-HANDLE=CAES6wEI2TIaEgk
+ czMsGPsxIQBF9BWnGoj8xQCJiChHEjGVza8OhIHJlcHVibGlrYRICQ1oaCk9sb21vdWNrw70qB0
+ 9sb21vdWMyB09sb21vdWM6Bjc3OSAwMFIKOC4ga3bEm3RuYVoFNTIyLzViEDguIGt2xJt0bmEgN
+ TIyLzUqEDguIGt2xJt0bmEgNTIyLzUyEDguIGt2xJt0bmEgNTIyLzUyDjc3OSAwMCBPbG9tb3Vj
+ MhHEjGVza8OhIHJlcHVibGlrYTg5QABaIwohEhIJHMzLBj7MSEARfQVpxqI/MUAY2TIgh6v++tO
+ opP88;X-APPLE-RADIUS=70.58737122607558;X-APPLE-REFERENCEFRAME=1;X-TITLE="Pi
+ colo - Pirátské centrum Olomouc, 8. května 522/5, 779 00 Olomouc, Česko":ge
+ o:49.595643,17.248577
+END:VEVENT
+BEGIN:VEVENT
+DTSTART:20200324T170000Z
+DTEND:20200324T183000Z
+DTSTAMP:20200519T130457Z
+UID:0lgp3r3r3s6a8fu0ubedd0lfov@google.com
+CREATED:20200210T181941Z
+DESCRIPTION:Přednáší MUDr. Marti Moron
+LAST-MODIFIED:20200210T181941Z
+LOCATION:
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Je kouření stejně škodlivé jako vapování?
+TRANSP:OPAQUE
+END:VEVENT
+BEGIN:VEVENT
+DTSTART:20200217T173000Z
+DTEND:20200217T203000Z
+DTSTAMP:20200519T130457Z
+UID:ccr66c9p6kp6ab9k71j32b9k69im6b9p69hj8b9mchim2dpn6cp3idhl6o@google.com
+ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;X-NUM-GUE
+ STS=0:mailto:0014epo7k8kbgpgq3gaudeodnc@group.calendar.google.com
+CREATED:20200118T124251Z
+DESCRIPTION:
+LAST-MODIFIED:20200203T194315Z
+LOCATION:Nebe počká\, 20\, Kratochvílova 122\, Město\, 750 02 Přerov\, Česk
+ o
+SEQUENCE:2
+STATUS:CONFIRMED
+SUMMARY:Schůzka Piráti Přerov
+TRANSP:OPAQUE
+END:VEVENT
+BEGIN:VEVENT
+DTSTART:20200123T170000Z
+DTEND:20200123T200000Z
+DTSTAMP:20200519T130457Z
+UID:28at6d7pbb6u7un4c8dhk54n3m@google.com
+CREATED:20200120T201202Z
+DESCRIPTION:
+LAST-MODIFIED:20200120T201202Z
+LOCATION:The 27 Music Bar\, Školní 24\, 796 01 Prostějov\, Česko
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Veřejná schůze Prostějovských Pirátů
+TRANSP:TRANSPARENT
+END:VEVENT
+BEGIN:VEVENT
+DTSTART;VALUE=DATE:20200111
+DTEND;VALUE=DATE:20200113
+DTSTAMP:20200519T130457Z
+UID:c9i3gohj70rj8b9k65j68b9k70q3ibb16sqm4b9m6thm8dj4cpijcdhj6s@google.com
+CREATED:20191117T100010Z
+DESCRIPTION:
+LAST-MODIFIED:20200110T192704Z
+LOCATION:
+SEQUENCE:1
+STATUS:TENTATIVE
+SUMMARY:CF Ostrava
+TRANSP:OPAQUE
+X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC
+BEGIN:VALARM
+ACTION:AUDIO
+TRIGGER:-PT15H
+X-WR-ALARMUID:F3784E51-6388-41CD-B559-202442F2AE8D
+UID:F3784E51-6388-41CD-B559-202442F2AE8D
+ATTACH;VALUE=URI:Basso
+X-APPLE-DEFAULT-ALARM:TRUE
+ACKNOWLEDGED:20200110T192657Z
+END:VALARM
+END:VEVENT
+BEGIN:VEVENT
+DTSTART;TZID=Europe/Prague:20200115T180000
+DTEND;TZID=Europe/Prague:20200115T210000
+RRULE:FREQ=MONTHLY;BYDAY=3WE
+DTSTAMP:20200519T130457Z
+UID:mt8j5rq9jsh2p1ped9belnb234@google.com
+CREATED:20190906T082036Z
+DESCRIPTION:
+LAST-MODIFIED:20200109T171048Z
+LOCATION:
+SEQUENCE:3
+STATUS:CONFIRMED
+SUMMARY:Veřejná schůze Pirátů MS Olomouc
+TRANSP:OPAQUE
+END:VEVENT
+BEGIN:VEVENT
+DTSTART;TZID=Europe/Prague:20190410T180000
+DTEND;TZID=Europe/Prague:20190410T210000
+DTSTAMP:20200519T130457Z
+UID:kfj8altfbk38ils1cs1247kv6c@google.com
+RECURRENCE-ID;TZID=Europe/Prague:20190410T180000
+CREATED:20180926T191919Z
+DESCRIPTION:
+LAST-MODIFIED:20191213T161303Z
+LOCATION:Jeseník
+SEQUENCE:1
+STATUS:CONFIRMED
+SUMMARY:Veřejná schůze Pirátů XOLK v Jeseníku
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
diff --git a/tests/calendar_utils/snapshots/__init__.py b/tests/calendar_utils/snapshots/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/calendar_utils/snapshots/snap_test_models.py b/tests/calendar_utils/snapshots/snap_test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..2114165718b84c1fd6b2e99447221e1af00e12d6
--- /dev/null
+++ b/tests/calendar_utils/snapshots/snap_test_models.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import GenericRepr, Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_calendar__update_source 1'] = [
+    {
+        'all_day': False,
+        'begin': GenericRepr('FakeDatetime(2020, 1, 23, 17, 0, tzinfo=tzutc())'),
+        'description': '',
+        'end': GenericRepr('FakeDatetime(2020, 1, 23, 20, 0, tzinfo=tzutc())'),
+        'location': 'The 27 Music Bar, Školní 24, 796 01 Prostějov, Česko',
+        'name': 'Veřejná schůze Prostějovských Pirátů'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('FakeDatetime(2020, 1, 15, 18, 0, tzinfo=tzoffset(None, 3600))'),
+        'description': '',
+        'end': GenericRepr('FakeDatetime(2020, 1, 15, 21, 0, tzinfo=tzoffset(None, 3600))'),
+        'location': '',
+        'name': 'Veřejná schůze Pirátů MS Olomouc'
+    },
+    {
+        'all_day': True,
+        'begin': GenericRepr('FakeDatetime(2020, 1, 11, 0, 0, tzinfo=tzutc())'),
+        'description': '',
+        'end': GenericRepr('FakeDatetime(2020, 1, 13, 0, 0, tzinfo=tzutc())'),
+        'location': '',
+        'name': 'CF Ostrava'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('FakeDatetime(2020, 1, 7, 18, 0, tzinfo=tzoffset(None, 3600))'),
+        'description': '',
+        'end': GenericRepr('FakeDatetime(2020, 1, 7, 21, 0, tzinfo=tzoffset(None, 3600))'),
+        'location': 'PiCOlo, 8. května 522/5, 779 00 Olomouc, Česko',
+        'name': 'Veřejná schůze Pirátů Olomouckého kraje'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('FakeDatetime(2019, 4, 10, 18, 0, tzinfo=tzoffset(None, 7200))'),
+        'description': '',
+        'end': GenericRepr('FakeDatetime(2019, 4, 10, 21, 0, tzinfo=tzoffset(None, 7200))'),
+        'location': 'Jeseník',
+        'name': 'Veřejná schůze Pirátů XOLK v Jeseníku'
+    }
+]
+
+snapshots['test_calendar__update_source 2'] = [
+    {
+        'all_day': False,
+        'begin': GenericRepr('FakeDatetime(2020, 4, 8, 16, 0, tzinfo=tzutc())'),
+        'description': '',
+        'end': GenericRepr('FakeDatetime(2020, 4, 8, 17, 30, tzinfo=tzutc())'),
+        'location': 'Picolo - Pirátské centrum Olomouc, 8. května 522/5, 779 00 Olomouc, Česko',
+        'name': 'Setkání s místopředsedou Evropského parlamentu Marcelem Kolalokou'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('FakeDatetime(2020, 3, 24, 17, 0, tzinfo=tzutc())'),
+        'description': 'Přednáší MUDr. Marti Moron',
+        'end': GenericRepr('FakeDatetime(2020, 3, 24, 18, 30, tzinfo=tzutc())'),
+        'location': '',
+        'name': 'Je kouření stejně škodlivé jako vapování?'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('FakeDatetime(2020, 3, 1, 14, 0, tzinfo=tzutc())'),
+        'description': '''Tato událost má videohovor.
+Připojit se: https://meet.google.com/ahv-nrmw-kmp
++1 484-696-1205 PIN: 546973991#''',
+        'end': GenericRepr('FakeDatetime(2020, 3, 1, 17, 0, tzinfo=tzutc())'),
+        'location': 'Koliba & Pivovar U Tří králů, Finská 4592/8, 796 01 Prostějov,Česko',
+        'name': 'Veřejná schůze Prostějovských Pirátů 01.03.2020'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('FakeDatetime(2020, 2, 21, 17, 30, tzinfo=tzutc())'),
+        'description': 'Tradiční páteční deskohraní s Karlem Bezejmeným. K zapůjčení budou deskové hry všeho druhu – a to jak jednoduché, tak náročnější pro ty,kteří se nebojí u hry strávit delší dobu. Všechny pravidla Vám budou vysvětlena. Nemusíte se bát.',
+        'end': GenericRepr('FakeDatetime(2020, 2, 21, 21, 0, tzinfo=tzutc())'),
+        'location': 'Picolo - Pirátské centrum Olomouc, 8. května 522/5, 779 00 Olomouc, Česko',
+        'name': 'Páteční deskovky v Picolu - Karel Bezejmenný'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('FakeDatetime(2020, 2, 17, 17, 30, tzinfo=tzutc())'),
+        'description': '',
+        'end': GenericRepr('FakeDatetime(2020, 2, 17, 20, 30, tzinfo=tzutc())'),
+        'location': 'Nebe počká, 20, Kratochvílova 122, Město, 750 02 Přerov, Česko',
+        'name': 'Schůzka Piráti Přerov'
+    }
+]
diff --git a/tests/calendar_utils/snapshots/snap_test_parser.py b/tests/calendar_utils/snapshots/snap_test_parser.py
new file mode 100644
index 0000000000000000000000000000000000000000..e884ab98c4752c52a5d83bf398813cb8bfda51ac
--- /dev/null
+++ b/tests/calendar_utils/snapshots/snap_test_parser.py
@@ -0,0 +1,181 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import GenericRepr, Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_parse_ical 1'] = [
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-04-08T16:00:00+00:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-04-08T17:30:00+00:00]>'),
+        'location': 'Picolo - Pirátské centrum Olomouc, 8. května 522/5, 779 00 Olomouc, Česko',
+        'name': 'Setkání s místopředsedou Evropského parlamentu Marcelem Kolalokou'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-03-24T17:00:00+00:00]>'),
+        'description': 'Přednáší MUDr. Marti Moron',
+        'end': GenericRepr('<Arrow [2020-03-24T18:30:00+00:00]>'),
+        'location': '',
+        'name': 'Je kouření stejně škodlivé jako vapování?'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-03-01T14:00:00+00:00]>'),
+        'description': '''Tato událost má videohovor.
+Připojit se: https://meet.google.com/ahv-nrmw-kmp
++1 484-696-1205 PIN: 546973991#''',
+        'end': GenericRepr('<Arrow [2020-03-01T17:00:00+00:00]>'),
+        'location': 'Koliba & Pivovar U Tří králů, Finská 4592/8, 796 01 Prostějov,Česko',
+        'name': 'Veřejná schůze Prostějovských Pirátů 01.03.2020'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-02-21T17:30:00+00:00]>'),
+        'description': 'Tradiční páteční deskohraní s Karlem Bezejmeným. K zapůjčení budou deskové hry všeho druhu – a to jak jednoduché, tak náročnější pro ty,kteří se nebojí u hry strávit delší dobu. Všechny pravidla Vám budou vysvětlena. Nemusíte se bát.',
+        'end': GenericRepr('<Arrow [2020-02-21T21:00:00+00:00]>'),
+        'location': 'Picolo - Pirátské centrum Olomouc, 8. května 522/5, 779 00 Olomouc, Česko',
+        'name': 'Páteční deskovky v Picolu - Karel Bezejmenný'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-02-17T17:30:00+00:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-02-17T20:30:00+00:00]>'),
+        'location': 'Nebe počká, 20, Kratochvílova 122, Město, 750 02 Přerov, Česko',
+        'name': 'Schůzka Piráti Přerov'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-01-23T17:00:00+00:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-01-23T20:00:00+00:00]>'),
+        'location': 'The 27 Music Bar, Školní 24, 796 01 Prostějov, Česko',
+        'name': 'Veřejná schůze Prostějovských Pirátů'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-01-15T18:00:00+01:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-01-15T21:00:00+01:00]>'),
+        'location': '',
+        'name': 'Veřejná schůze Pirátů MS Olomouc'
+    },
+    {
+        'all_day': True,
+        'begin': GenericRepr('<Arrow [2020-01-11T00:00:00+00:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-01-13T00:00:00+00:00]>'),
+        'location': '',
+        'name': 'CF Ostrava'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-01-07T18:00:00+01:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-01-07T21:00:00+01:00]>'),
+        'location': 'PiCOlo, 8. května 522/5, 779 00 Olomouc, Česko',
+        'name': 'Veřejná schůze Pirátů Olomouckého kraje'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2019-04-10T18:00:00+02:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2019-04-10T21:00:00+02:00]>'),
+        'location': 'Jeseník',
+        'name': 'Veřejná schůze Pirátů XOLK v Jeseníku'
+    }
+]
+
+snapshots['test_split_events 1'] = [
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-01-23T17:00:00+00:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-01-23T20:00:00+00:00]>'),
+        'location': 'The 27 Music Bar, Školní 24, 796 01 Prostějov, Česko',
+        'name': 'Veřejná schůze Prostějovských Pirátů'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-01-15T18:00:00+01:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-01-15T21:00:00+01:00]>'),
+        'location': '',
+        'name': 'Veřejná schůze Pirátů MS Olomouc'
+    },
+    {
+        'all_day': True,
+        'begin': GenericRepr('<Arrow [2020-01-11T00:00:00+00:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-01-13T00:00:00+00:00]>'),
+        'location': '',
+        'name': 'CF Ostrava'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-01-07T18:00:00+01:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-01-07T21:00:00+01:00]>'),
+        'location': 'PiCOlo, 8. května 522/5, 779 00 Olomouc, Česko',
+        'name': 'Veřejná schůze Pirátů Olomouckého kraje'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2019-04-10T18:00:00+02:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2019-04-10T21:00:00+02:00]>'),
+        'location': 'Jeseník',
+        'name': 'Veřejná schůze Pirátů XOLK v Jeseníku'
+    }
+]
+
+snapshots['test_split_events 2'] = [
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-04-08T16:00:00+00:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-04-08T17:30:00+00:00]>'),
+        'location': 'Picolo - Pirátské centrum Olomouc, 8. května 522/5, 779 00 Olomouc, Česko',
+        'name': 'Setkání s místopředsedou Evropského parlamentu Marcelem Kolalokou'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-03-24T17:00:00+00:00]>'),
+        'description': 'Přednáší MUDr. Marti Moron',
+        'end': GenericRepr('<Arrow [2020-03-24T18:30:00+00:00]>'),
+        'location': '',
+        'name': 'Je kouření stejně škodlivé jako vapování?'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-03-01T14:00:00+00:00]>'),
+        'description': '''Tato událost má videohovor.
+Připojit se: https://meet.google.com/ahv-nrmw-kmp
++1 484-696-1205 PIN: 546973991#''',
+        'end': GenericRepr('<Arrow [2020-03-01T17:00:00+00:00]>'),
+        'location': 'Koliba & Pivovar U Tří králů, Finská 4592/8, 796 01 Prostějov,Česko',
+        'name': 'Veřejná schůze Prostějovských Pirátů 01.03.2020'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-02-21T17:30:00+00:00]>'),
+        'description': 'Tradiční páteční deskohraní s Karlem Bezejmeným. K zapůjčení budou deskové hry všeho druhu – a to jak jednoduché, tak náročnější pro ty,kteří se nebojí u hry strávit delší dobu. Všechny pravidla Vám budou vysvětlena. Nemusíte se bát.',
+        'end': GenericRepr('<Arrow [2020-02-21T21:00:00+00:00]>'),
+        'location': 'Picolo - Pirátské centrum Olomouc, 8. května 522/5, 779 00 Olomouc, Česko',
+        'name': 'Páteční deskovky v Picolu - Karel Bezejmenný'
+    },
+    {
+        'all_day': False,
+        'begin': GenericRepr('<Arrow [2020-02-17T17:30:00+00:00]>'),
+        'description': '',
+        'end': GenericRepr('<Arrow [2020-02-17T20:30:00+00:00]>'),
+        'location': 'Nebe počká, 20, Kratochvílova 122, Město, 750 02 Přerov, Česko',
+        'name': 'Schůzka Piráti Přerov'
+    }
+]
diff --git a/tests/calendar_utils/test_models.py b/tests/calendar_utils/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..b81e21484bcc58e40b00f2601fd09b5137adc378
--- /dev/null
+++ b/tests/calendar_utils/test_models.py
@@ -0,0 +1,81 @@
+import arrow
+import pytest
+import pytz
+from faker import Faker
+
+from calendar_utils.models import Calendar
+
+pytestmark = pytest.mark.django_db
+
+fake = Faker()
+
+
+@pytest.mark.freeze_time("2020-02-02")
+def test_calendar__update_source(sample, mocker, snapshot):
+    m_get = mocker.patch(
+        "calendar_utils.models.requests.get", return_value=mocker.Mock(text=sample)
+    )
+    url = "http://foo"
+
+    cal = Calendar.objects.create(url=url)
+    cal.update_source()
+
+    m_get.assert_called_once_with(url)
+    cal.refresh_from_db()
+    assert cal.source == sample
+    assert cal.last_update == arrow.get("2020-02-02").datetime
+    snapshot.assert_match(cal.past_events)
+    snapshot.assert_match(cal.future_events)
+
+
+@pytest.mark.freeze_time("2020-03-30")
+def test_calendar__update_source__unchanged_source(sample, mocker):
+    m_get = mocker.patch(
+        "calendar_utils.models.requests.get", return_value=mocker.Mock(text=sample)
+    )
+    url = "http://foo"
+
+    cal = Calendar.objects.create(url=url, source=sample)
+    cal.update_source()
+
+    m_get.assert_called_once_with(url)
+    cal.refresh_from_db()
+    assert cal.source == sample
+    assert cal.last_update == arrow.get("2020-03-30").datetime
+    assert cal.past_events is None
+    assert cal.future_events is None
+
+
+def test_calendar__save_and_load_events():
+    past_events = [
+        {
+            "begin": fake.date_time(tzinfo=pytz.UTC),
+            "end": fake.date_time(tzinfo=pytz.UTC),
+        },
+        {
+            "begin": fake.date_time(tzinfo=pytz.UTC),
+            "end": fake.date_time(tzinfo=pytz.UTC),
+        },
+    ]
+    future_events = [
+        {
+            "begin": fake.date_time(tzinfo=pytz.UTC),
+            "end": fake.date_time(tzinfo=pytz.UTC),
+        },
+    ]
+
+    cal = Calendar.objects.create(
+        url=fake.url(), past_events=past_events, future_events=future_events
+    )
+
+    cal.refresh_from_db()
+    assert cal.past_events == past_events
+    assert cal.future_events == future_events
+
+
+def test_calendar__save_and_load_events__no_values():
+    cal = Calendar.objects.create(url=fake.url())
+
+    cal.refresh_from_db()
+    assert cal.past_events is None
+    assert cal.future_events is None
diff --git a/tests/calendar_utils/test_parser.py b/tests/calendar_utils/test_parser.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ad885209f75d4cfad79bd43a7cdc13b94c4112c
--- /dev/null
+++ b/tests/calendar_utils/test_parser.py
@@ -0,0 +1,15 @@
+import pytest
+
+from calendar_utils.parser import parse_ical, split_events
+
+
+def test_parse_ical(sample, snapshot):
+    events = parse_ical(sample)
+    snapshot.assert_match(events)
+
+
+@pytest.mark.freeze_time("2020-02-02")
+def test_split_events(sample, snapshot):
+    past_events, future_events = split_events(parse_ical(sample))
+    snapshot.assert_match(past_events)
+    snapshot.assert_match(future_events)