diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 28c0603bd8fbbbf2094b2a9e76a1fb31b1869024..7c730cffef8acc73b747a8590f5510ba43450e08 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -17,11 +17,34 @@ cache:
 before_script:
   - python -V  # Print out python version for debugging
   - apt-get -qq update
-  - apt-get -qq install -y python3-virtualenv python3 python3-dev python3-pip gettext default-libmysqlclient-dev
+  - apt-get -qq install -y python3-virtualenv python3 python3-dev python3-pip gettext default-mysql-client default-libmysqlclient-dev
   - export DJANGO_SETTINGS_MODULE=AKPlanning.settings_ci
   - ./Utils/setup.sh --prod
+  - mysql --version
+
+check:
+  script:
+    - ./Utils/check.sh --all
+
+check-migrations:
+  script:
+    - source venv/bin/activate
+    - ./manage.py makemigrations --dry-run --check
 
 test:
   script:
     - source venv/bin/activate
-    - python manage.py test --settings AKPlanning.settings_ci --keepdb
+    - echo "GRANT ALL on *.* to '${MYSQL_USER}';"| mysql -u root --password="${MYSQL_ROOT_PASSWORD}" -h mysql
+    - pip install pytest-cov unittest-xml-reporting
+    - coverage run --source='.' manage.py test --settings AKPlanning.settings_ci
+  after_script:
+    - source venv/bin/activate
+    - coverage report
+    - coverage xml
+  coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
+  artifacts:
+    reports:
+      coverage_report:
+        coverage_format: cobertura
+        path: coverage.xml
+      junit: unit.xml
diff --git a/AKDashboard/fixtures/dashboard.json b/AKDashboard/fixtures/dashboard.json
new file mode 100644
index 0000000000000000000000000000000000000000..dde1a64d08ef64648bf7e862ef2aa33e15805c0a
--- /dev/null
+++ b/AKDashboard/fixtures/dashboard.json
@@ -0,0 +1,13 @@
+[
+{
+    "model": "AKDashboard.dashboardbutton",
+    "pk": 1,
+    "fields": {
+        "text": "Wiki",
+        "url": "http://wiki.kif.rocks",
+        "icon": "fab,wikipedia-w",
+        "color": 2,
+        "event": 2
+    }
+}
+]
diff --git a/AKDashboard/tests.py b/AKDashboard/tests.py
index f4fbff87e12c28df6ca244a5cf046d939d5b06cc..9610f5a22ad8f68aea735342f1fd18d034ba5c7b 100644
--- a/AKDashboard/tests.py
+++ b/AKDashboard/tests.py
@@ -6,6 +6,7 @@ from django.utils.timezone import now
 
 from AKDashboard.models import DashboardButton
 from AKModel.models import Event, AK, AKCategory
+from AKModel.tests import BasicViewTests
 
 
 class DashboardTests(TestCase):
@@ -64,7 +65,6 @@ class DashboardTests(TestCase):
         self.event.public = False
         self.event.save()
         response = self.client.get(url_dashboard_index)
-        print(response)
         self.assertEqual(response.status_code, 200)
         self.assertFalse(self.event in response.context["events"])
         response = self.client.get(url_event_dashboard)
@@ -126,3 +126,14 @@ class DashboardTests(TestCase):
 
         response = self.client.get(url_event_dashboard)
         self.assertContains(response, "Dashboard Button Test")
+
+
+class DashboardViewTests(BasicViewTests, TestCase):
+    fixtures = ['model.json', 'dashboard.json']
+
+    APP_NAME = 'dashboard'
+
+    VIEWS = [
+        ('dashboard', {}),
+        ('dashboard_event', {'slug': 'kif42'}),
+    ]
diff --git a/AKModel/fixtures/model.json b/AKModel/fixtures/model.json
new file mode 100644
index 0000000000000000000000000000000000000000..b5bdcf01084502a30fce82ccb79d90cc83f46ca8
--- /dev/null
+++ b/AKModel/fixtures/model.json
@@ -0,0 +1,683 @@
+[
+{
+    "model": "AKModel.event",
+    "pk": 1,
+    "fields": {
+        "name": "KIF 23",
+        "slug": "kif23",
+        "place": "Neuland",
+        "timezone": "Europe/Berlin",
+        "start": "2020-10-01T17:41:22Z",
+        "end": "2020-10-04T17:41:30Z",
+        "reso_deadline": "2020-10-03T10:00:00Z",
+        "interest_start": null,
+        "interest_end": null,
+        "public": true,
+        "active": false,
+        "plan_hidden": true,
+        "plan_published_at": null,
+        "base_url": "",
+        "wiki_export_template_name": "",
+        "default_slot": "2.00",
+        "contact_email": "dummy@eyample.org"
+    }
+},
+{
+    "model": "AKModel.event",
+    "pk": 2,
+    "fields": {
+        "name": "KIF 42",
+        "slug": "kif42",
+        "place": "Neuland noch neuer",
+        "timezone": "Europe/Berlin",
+        "start": "2020-11-06T17:51:26Z",
+        "end": "2020-11-10T17:51:27Z",
+        "reso_deadline": "2020-11-08T17:51:42Z",
+        "interest_start": null,
+        "interest_end": null,
+        "public": true,
+        "active": true,
+        "plan_hidden": false,
+        "plan_published_at": "2022-12-01T21:50:01Z",
+        "base_url": "",
+        "wiki_export_template_name": "",
+        "default_slot": "2.00",
+        "contact_email": "dummy@example.org"
+    }
+},
+{
+    "model": "AKModel.akowner",
+    "pk": 1,
+    "fields": {
+        "name": "a",
+        "slug": "a",
+        "institution": "Uni X",
+        "link": "",
+        "event": 2
+    }
+},
+{
+    "model": "AKModel.akowner",
+    "pk": 2,
+    "fields": {
+        "name": "b",
+        "slug": "b",
+        "institution": "Hochschule Y",
+        "link": "",
+        "event": 2
+    }
+},
+{
+    "model": "AKModel.akowner",
+    "pk": 3,
+    "fields": {
+        "name": "c",
+        "slug": "c",
+        "institution": "",
+        "link": "",
+        "event": 1
+    }
+},
+{
+    "model": "AKModel.akowner",
+    "pk": 4,
+    "fields": {
+        "name": "d",
+        "slug": "d",
+        "institution": "",
+        "link": "",
+        "event": 1
+    }
+},
+{
+    "model": "AKModel.akcategory",
+    "pk": 1,
+    "fields": {
+        "name": "Spaâ–€",
+        "color": "275246",
+        "description": "",
+        "present_by_default": true,
+        "event": 1
+    }
+},
+{
+    "model": "AKModel.akcategory",
+    "pk": 2,
+    "fields": {
+        "name": "Ernst",
+        "color": "234567",
+        "description": "",
+        "present_by_default": true,
+        "event": 1
+    }
+},
+{
+    "model": "AKModel.akcategory",
+    "pk": 3,
+    "fields": {
+        "name": "Spaâ–€/Kultur",
+        "color": "333333",
+        "description": "",
+        "present_by_default": true,
+        "event": 2
+    }
+},
+{
+    "model": "AKModel.akcategory",
+    "pk": 4,
+    "fields": {
+        "name": "Inhalt",
+        "color": "456789",
+        "description": "",
+        "present_by_default": true,
+        "event": 2
+    }
+},
+{
+    "model": "AKModel.akcategory",
+    "pk": 5,
+    "fields": {
+        "name": "Meta",
+        "color": "111111",
+        "description": "",
+        "present_by_default": true,
+        "event": 2
+    }
+},
+{
+    "model": "AKModel.aktrack",
+    "pk": 1,
+    "fields": {
+        "name": "Track 1",
+        "color": "",
+        "event": 2
+    }
+},
+{
+    "model": "AKModel.aktag",
+    "pk": 1,
+    "fields": {
+        "name": "metametameta"
+    }
+},
+{
+    "model": "AKModel.akrequirement",
+    "pk": 1,
+    "fields": {
+        "name": "Beamer",
+        "event": 1
+    }
+},
+{
+    "model": "AKModel.akrequirement",
+    "pk": 2,
+    "fields": {
+        "name": "Internet",
+        "event": 1
+    }
+},
+{
+    "model": "AKModel.akrequirement",
+    "pk": 3,
+    "fields": {
+        "name": "BBB",
+        "event": 2
+    }
+},
+{
+    "model": "AKModel.akrequirement",
+    "pk": 4,
+    "fields": {
+        "name": "Mumble",
+        "event": 2
+    }
+},
+{
+    "model": "AKModel.akrequirement",
+    "pk": 5,
+    "fields": {
+        "name": "Matrix",
+        "event": 2
+    }
+},
+{
+    "model": "AKModel.historicalak",
+    "pk": 1,
+    "fields": {
+        "id": 1,
+        "name": "Test AK Inhalt",
+        "short_name": "test1",
+        "description": "",
+        "link": "",
+        "protocol_link": "",
+        "reso": false,
+        "present": true,
+        "notes": "",
+        "interest": -1,
+        "category": 4,
+        "track": null,
+        "event": 2,
+        "history_date": "2021-05-04T10:28:59.265Z",
+        "history_change_reason": null,
+        "history_type": "+",
+        "history_user": null
+    }
+},
+{
+    "model": "AKModel.historicalak",
+    "pk": 2,
+    "fields": {
+        "id": 1,
+        "name": "Test AK Inhalt",
+        "short_name": "test1",
+        "description": "",
+        "link": "",
+        "protocol_link": "",
+        "reso": false,
+        "present": true,
+        "notes": "",
+        "interest": -1,
+        "category": 4,
+        "track": null,
+        "event": 2,
+        "history_date": "2021-05-04T10:28:59.305Z",
+        "history_change_reason": null,
+        "history_type": "~",
+        "history_user": null
+    }
+},
+{
+    "model": "AKModel.historicalak",
+    "pk": 3,
+    "fields": {
+        "id": 2,
+        "name": "Test AK Meta",
+        "short_name": "test2",
+        "description": "",
+        "link": "",
+        "protocol_link": "",
+        "reso": false,
+        "present": null,
+        "notes": "",
+        "interest": -1,
+        "category": 5,
+        "track": null,
+        "event": 2,
+        "history_date": "2021-05-04T10:29:40.296Z",
+        "history_change_reason": null,
+        "history_type": "+",
+        "history_user": null
+    }
+},
+{
+    "model": "AKModel.historicalak",
+    "pk": 4,
+    "fields": {
+        "id": 2,
+        "name": "Test AK Meta",
+        "short_name": "test2",
+        "description": "",
+        "link": "",
+        "protocol_link": "",
+        "reso": false,
+        "present": null,
+        "notes": "",
+        "interest": -1,
+        "category": 5,
+        "track": null,
+        "event": 2,
+        "history_date": "2021-05-04T10:29:40.355Z",
+        "history_change_reason": null,
+        "history_type": "~",
+        "history_user": null
+    }
+},
+{
+    "model": "AKModel.historicalak",
+    "pk": 5,
+    "fields": {
+        "id": 3,
+        "name": "AK Wish",
+        "short_name": "wish1",
+        "description": "Description of my Wish",
+        "link": "",
+        "protocol_link": "",
+        "reso": false,
+        "present": null,
+        "notes": "We need to find a volunteer first...",
+        "interest": -1,
+        "category": 3,
+        "track": null,
+        "event": 2,
+        "history_date": "2021-05-04T10:30:59.469Z",
+        "history_change_reason": null,
+        "history_type": "+",
+        "history_user": null
+    }
+},
+{
+    "model": "AKModel.historicalak",
+    "pk": 6,
+    "fields": {
+        "id": 3,
+        "name": "AK Wish",
+        "short_name": "wish1",
+        "description": "Description of my Wish",
+        "link": "",
+        "protocol_link": "",
+        "reso": false,
+        "present": null,
+        "notes": "We need to find a volunteer first...",
+        "interest": -1,
+        "category": 3,
+        "track": null,
+        "event": 2,
+        "history_date": "2021-05-04T10:30:59.509Z",
+        "history_change_reason": null,
+        "history_type": "~",
+        "history_user": null
+    }
+},
+{
+    "model": "AKModel.historicalak",
+    "pk": 7,
+    "fields": {
+        "id": 2,
+        "name": "Test AK Meta",
+        "short_name": "test2",
+        "description": "",
+        "link": "",
+        "protocol_link": "",
+        "reso": false,
+        "present": null,
+        "notes": "",
+        "interest": -1,
+        "category": 5,
+        "track": 1,
+        "event": 2,
+        "history_date": "2022-12-02T12:27:14.277Z",
+        "history_change_reason": null,
+        "history_type": "~",
+        "history_user": null
+    }
+},
+{
+    "model": "AKModel.ak",
+    "pk": 1,
+    "fields": {
+        "name": "Test AK Inhalt",
+        "short_name": "test1",
+        "description": "",
+        "link": "",
+        "protocol_link": "",
+        "category": 4,
+        "track": null,
+        "reso": false,
+        "present": true,
+        "notes": "",
+        "interest": -1,
+        "interest_counter": 0,
+        "event": 2,
+        "owners": [
+            1
+        ],
+        "tags": [],
+        "requirements": [
+            3
+        ],
+        "conflicts": [],
+        "prerequisites": []
+    }
+},
+{
+    "model": "AKModel.ak",
+    "pk": 2,
+    "fields": {
+        "name": "Test AK Meta",
+        "short_name": "test2",
+        "description": "",
+        "link": "",
+        "protocol_link": "",
+        "category": 5,
+        "track": 1,
+        "reso": false,
+        "present": null,
+        "notes": "",
+        "interest": -1,
+        "interest_counter": 0,
+        "event": 2,
+        "owners": [
+            2
+        ],
+        "tags": [
+            1
+        ],
+        "requirements": [],
+        "conflicts": [],
+        "prerequisites": []
+    }
+},
+{
+    "model": "AKModel.ak",
+    "pk": 3,
+    "fields": {
+        "name": "AK Wish",
+        "short_name": "wish1",
+        "description": "Description of my Wish",
+        "link": "",
+        "protocol_link": "",
+        "category": 3,
+        "track": null,
+        "reso": false,
+        "present": null,
+        "notes": "We need to find a volunteer first...",
+        "interest": -1,
+        "interest_counter": 0,
+        "event": 2,
+        "owners": [],
+        "tags": [],
+        "requirements": [
+            4
+        ],
+        "conflicts": [
+            1
+        ],
+        "prerequisites": [
+            2
+        ]
+    }
+},
+{
+    "model": "AKModel.room",
+    "pk": 1,
+    "fields": {
+        "name": "Testraum",
+        "location": "BBB",
+        "capacity": -1,
+        "event": 2,
+        "properties": [
+            3
+        ]
+    }
+},
+{
+    "model": "AKModel.room",
+    "pk": 2,
+    "fields": {
+        "name": "Testraum 2",
+        "location": "",
+        "capacity": 30,
+        "event": 2,
+        "properties": []
+    }
+},
+{
+    "model": "AKModel.akslot",
+    "pk": 1,
+    "fields": {
+        "ak": 1,
+        "room": 2,
+        "start": "2020-11-06T18:30:00Z",
+        "duration": "2.00",
+        "fixed": false,
+        "event": 2,
+        "updated": "2022-12-02T12:23:11.856Z"
+    }
+},
+{
+    "model": "AKModel.akslot",
+    "pk": 2,
+    "fields": {
+        "ak": 2,
+        "room": 1,
+        "start": "2020-11-08T12:30:00Z",
+        "duration": "2.00",
+        "fixed": false,
+        "event": 2,
+        "updated": "2022-12-02T12:23:18.279Z"
+    }
+},
+{
+    "model": "AKModel.akslot",
+    "pk": 3,
+    "fields": {
+        "ak": 3,
+        "room": null,
+        "start": null,
+        "duration": "1.50",
+        "fixed": false,
+        "event": 2,
+        "updated": "2021-05-04T10:30:59.523Z"
+    }
+},
+{
+    "model": "AKModel.akslot",
+    "pk": 4,
+    "fields": {
+        "ak": 3,
+        "room": null,
+        "start": null,
+        "duration": "3.00",
+        "fixed": false,
+        "event": 2,
+        "updated": "2021-05-04T10:30:59.528Z"
+    }
+},
+{
+    "model": "AKModel.akslot",
+    "pk": 5,
+    "fields": {
+        "ak": 1,
+        "room": null,
+        "start": null,
+        "duration": "2.00",
+        "fixed": false,
+        "event": 2,
+        "updated": "2022-12-02T12:23:11.856Z"
+    }
+},
+{
+    "model": "AKModel.constraintviolation",
+    "pk": 1,
+    "fields": {
+        "type": "soa",
+        "level": 10,
+        "event": 2,
+        "ak_owner": null,
+        "room": null,
+        "requirement": null,
+        "category": null,
+        "comment": "",
+        "timestamp": "2022-12-02T12:23:11.875Z",
+        "manually_resolved": false,
+        "aks": [
+            1
+        ],
+        "ak_slots": [
+            1
+        ]
+    }
+},
+{
+    "model": "AKModel.constraintviolation",
+    "pk": 2,
+    "fields": {
+        "type": "rng",
+        "level": 10,
+        "event": 2,
+        "ak_owner": null,
+        "room": 2,
+        "requirement": 3,
+        "category": null,
+        "comment": "",
+        "timestamp": "2022-12-02T12:23:11.890Z",
+        "manually_resolved": false,
+        "aks": [
+            1
+        ],
+        "ak_slots": [
+            1
+        ]
+    }
+},
+{
+    "model": "AKModel.constraintviolation",
+    "pk": 3,
+    "fields": {
+        "type": "soa",
+        "level": 10,
+        "event": 2,
+        "ak_owner": null,
+        "room": null,
+        "requirement": null,
+        "category": null,
+        "comment": "",
+        "timestamp": "2022-12-02T12:23:18.301Z",
+        "manually_resolved": false,
+        "aks": [
+            2
+        ],
+        "ak_slots": [
+            2
+        ]
+    }
+},
+{
+    "model": "AKModel.availability",
+    "pk": 1,
+    "fields": {
+        "event": 2,
+        "person": null,
+        "room": null,
+        "ak": 1,
+        "ak_category": null,
+        "start": "2020-11-07T08:00:00Z",
+        "end": "2020-11-07T18:00:00Z"
+    }
+},
+{
+    "model": "AKModel.availability",
+    "pk": 2,
+    "fields": {
+        "event": 2,
+        "person": null,
+        "room": null,
+        "ak": 1,
+        "ak_category": null,
+        "start": "2020-11-09T10:00:00Z",
+        "end": "2020-11-09T16:30:00Z"
+    }
+},
+{
+    "model": "AKModel.availability",
+    "pk": 3,
+    "fields": {
+        "event": 2,
+        "person": null,
+        "room": 1,
+        "ak": null,
+        "ak_category": null,
+        "start": "2020-11-06T17:51:26Z",
+        "end": "2020-11-10T23:00:00Z"
+    }
+},
+{
+    "model": "AKModel.availability",
+    "pk": 4,
+    "fields": {
+        "event": 2,
+        "person": null,
+        "room": 2,
+        "ak": null,
+        "ak_category": null,
+        "start": "2020-11-06T17:51:26Z",
+        "end": "2020-11-06T21:00:00Z"
+    }
+},
+{
+    "model": "AKModel.availability",
+    "pk": 5,
+    "fields": {
+        "event": 2,
+        "person": null,
+        "room": 2,
+        "ak": null,
+        "ak_category": null,
+        "start": "2020-11-08T15:30:00Z",
+        "end": "2020-11-08T19:30:00Z"
+    }
+},
+{
+    "model": "AKModel.availability",
+    "pk": 6,
+    "fields": {
+        "event": 2,
+        "person": null,
+        "room": 2,
+        "ak": null,
+        "ak_category": null,
+        "start": "2020-11-07T18:30:00Z",
+        "end": "2020-11-07T21:30:00Z"
+    }
+}
+]
diff --git a/AKModel/tests.py b/AKModel/tests.py
index a39b155ac3ee946fb97efafe6ecbb42f571cd7ad..106de0979c99432f1589ab5be16909fc8d70247d 100644
--- a/AKModel/tests.py
+++ b/AKModel/tests.py
@@ -1 +1,112 @@
-# Create your tests here.
+from typing import List
+
+from django.contrib.auth.models import User
+from django.contrib.messages import get_messages
+from django.contrib.messages.storage.base import Message
+from django.test import TestCase
+from django.urls import reverse_lazy
+
+from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, AK, Room, AKSlot, AKOrgaMessage, \
+    ConstraintViolation, DefaultSlot
+
+
+class BasicViewTests:
+    VIEWS = []
+    APP_NAME = ''
+    VIEWS_STAFF_ONLY = []
+
+    def setUp(self):
+        self.staff_user = User.objects.create(
+            username='Test Staff User', email='teststaff@example.com', password='staffpw',
+            is_staff=True, is_active=True
+        )
+        self.admin_user = User.objects.create(
+            username='Test Admin User', email='testadmin@example.com', password='adminpw',
+            is_staff=True, is_superuser=True, is_active=True
+        )
+        self.deactivated_user = User.objects.create(
+            username='Test Deactivated User', email='testdeactivated@example.com', password='deactivatedpw',
+            is_staff=True, is_active=False
+        )
+
+    def _name_and_url(self, view_name):
+        """
+        Get full view name (with prefix if there is one) and url from raw view definition
+
+        :param view_name: raw definition of a view
+        :type view_name: (str, dict)
+        :return: full view name with prefix if applicable, url of the view
+        :rtype: str, str
+        """
+        view_name_with_prefix = f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0]
+        url = reverse_lazy(view_name_with_prefix, kwargs=view_name[1])
+        return view_name_with_prefix, url
+
+    def _assert_message(self, response, expected_message, msg_prefix=""):
+        messages:List[Message] = list(get_messages(response.wsgi_request))
+
+        msg_count = "No message shown to user"
+        msg_content = "Wrong message, expected '{expected_message}'"
+        if msg_prefix != "":
+            msg_count = f"{msg_prefix}: {msg_count}"
+            msg_content = f"{msg_prefix}: {msg_content}"
+
+        # Check that the last message correctly reports the issue
+        # (there might be more messages from previous calls that were not already rendered)
+        self.assertGreater(len(messages), 0, msg=msg_count)
+        self.assertEqual(messages[-1].message, expected_message, msg=msg_content)
+
+    def test_views_for_200(self):
+        for view_name in self.VIEWS:
+            view_name_with_prefix, url = self._name_and_url(view_name)
+            response = self.client.get(url)
+            self.assertEqual(response.status_code, 200, msg=f"{view_name_with_prefix} ({url}) broken")
+
+    def test_access_control_staff_only(self):
+        self.client.logout()
+        for view_name in self.VIEWS_STAFF_ONLY:
+            view_name_with_prefix, url = self._name_and_url(view_name)
+            response = self.client.get(url)
+            self.assertEqual(response.status_code, 302, msg=f"{view_name_with_prefix} ({url}) accessible by non-staff")
+
+        self.client.force_login(self.staff_user)
+        for view_name in self.VIEWS_STAFF_ONLY:
+            view_name_with_prefix, url = self._name_and_url(view_name)
+            response = self.client.get(url)
+            self.assertEqual(response.status_code, 200,
+                             msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)")
+
+        self.client.force_login(self.deactivated_user)
+        for view_name in self.VIEWS_STAFF_ONLY:
+            view_name_with_prefix, url = self._name_and_url(view_name)
+            response = self.client.get(url)
+            self.assertEqual(response.status_code, 302,
+                             msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user")
+
+
+class ModelViewTests(BasicViewTests, TestCase):
+    fixtures = ['model.json']
+
+    ADMIN_MODELS = [
+        (Event, 'event'), (AKOwner, 'akowner'), (AKCategory, 'akcategory'), (AKTrack, 'aktrack'),
+        (AKRequirement, 'akrequirement'), (AK, 'ak'), (Room, 'room'), (AKSlot, 'akslot'),
+        (AKOrgaMessage, 'akorgamessage'), (ConstraintViolation, 'constraintviolation'),
+        (DefaultSlot, 'defaultslot')
+    ]
+
+    def test_admin(self):
+        self.client.force_login(self.admin_user)
+
+        for model in self.ADMIN_MODELS:
+            if model[1] == "event":
+                continue
+            view_name_with_prefix, url = self._name_and_url((f'admin:AKModel_{model[1]}_add', {}))
+            response = self.client.get(url)
+            self.assertEqual(response.status_code, 200, msg=f"Add form for model {model[1]} ({url}) broken")
+
+        for model in self.ADMIN_MODELS:
+            m = model[0].objects.first()
+            if m is not None:
+                view_name_with_prefix, url = self._name_and_url((f'admin:AKModel_{model[1]}_change', {'object_id': m.pk}))
+                response = self.client.get(url)
+                self.assertEqual(response.status_code, 200, msg=f"Edit form for model {model[1]} ({url}) broken")
diff --git a/AKPlan/tests.py b/AKPlan/tests.py
index a39b155ac3ee946fb97efafe6ecbb42f571cd7ad..13f671c6a965e3ccaca873019c6ef330c8efaff1 100644
--- a/AKPlan/tests.py
+++ b/AKPlan/tests.py
@@ -1 +1,28 @@
-# Create your tests here.
+from django.test import TestCase
+
+from AKModel.tests import BasicViewTests
+
+
+class PlanViewTests(BasicViewTests, TestCase):
+    fixtures = ['model.json']
+    APP_NAME = 'plan'
+
+    VIEWS = [
+        ('plan_overview', {'event_slug': 'kif42'}),
+        ('plan_wall', {'event_slug': 'kif42'}),
+        ('plan_room', {'event_slug': 'kif42', 'pk': 2}),
+        ('plan_track', {'event_slug': 'kif42', 'pk': 1}),
+    ]
+
+    def test_plan_hidden(self):
+        view_name_with_prefix, url = self._name_and_url(('plan_overview', {'event_slug': 'kif23'}))
+
+        self.client.logout()
+        response = self.client.get(url)
+        self.assertContains(response, "Plan is not visible (yet).",
+                            msg_prefix="Plan is visible even though it shouldn't be")
+
+        self.client.force_login(self.staff_user)
+        response = self.client.get(url)
+        self.assertNotContains(response, "Plan is not visible (yet).",
+                               msg_prefix="Plan is not visible for staff user")
diff --git a/AKPlanning/settings_ci.py b/AKPlanning/settings_ci.py
index 99aa3a7a644abeb993403e35a65b74cdfa87a989..c8e6a62e272c2d8985707e25537400983271f2a0 100644
--- a/AKPlanning/settings_ci.py
+++ b/AKPlanning/settings_ci.py
@@ -16,10 +16,14 @@ DATABASES = {
         'PASSWORD': 'mysql',
         'OPTIONS': {
             'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
-            'charset': "utf8mb4",
         },
         'TEST': {
-            'NAME': 'test',
+            'NAME': 'tests',
+            'CHARSET': "utf8mb4",
+            'COLLATION': 'utf8mb4_unicode_ci',
         },
     }
 }
+
+TEST_RUNNER = 'xmlrunner.extra.djangotestrunner.XMLTestRunner'
+TEST_OUTPUT_FILE_NAME = 'unit.xml'
diff --git a/AKScheduling/tests.py b/AKScheduling/tests.py
index a39b155ac3ee946fb97efafe6ecbb42f571cd7ad..8b7bcf91eeb40abe1185b814b0018c2f36715c51 100644
--- a/AKScheduling/tests.py
+++ b/AKScheduling/tests.py
@@ -1 +1,17 @@
-# Create your tests here.
+from django.test import TestCase
+from AKModel.tests import BasicViewTests
+
+
+class ModelViewTests(BasicViewTests, TestCase):
+    fixtures = ['model.json']
+
+    VIEWS_STAFF_ONLY = [
+        ('admin:schedule', {'event_slug': 'kif42'}),
+        ('admin:slots_unscheduled', {'event_slug': 'kif42'}),
+        ('admin:constraint-violations', {'slug': 'kif42'}),
+        ('admin:special-attention', {'slug': 'kif42'}),
+        ('admin:cleanup-wish-slots', {'event_slug': 'kif42'}),
+        ('admin:autocreate-availabilities', {'event_slug': 'kif42'}),
+        ('admin:tracks_manage', {'event_slug': 'kif42'}),
+        ('admin:enter-interest', {'event_slug': 'kif42', 'pk': 1}),
+    ]
diff --git a/AKSubmission/api.py b/AKSubmission/api.py
index 29a6f42a9f371b53aef98ead6a4d7b2b4d6e20f3..db46d40c0455c0553bb311302cc39145c41af1f7 100644
--- a/AKSubmission/api.py
+++ b/AKSubmission/api.py
@@ -26,8 +26,8 @@ def increment_interest_counter(request, event_slug, pk, **kwargs):
     """
     Increment interest counter for AK
     """
-    ak = AK.objects.get(pk=pk)
-    if ak:
+    try:
+        ak = AK.objects.get(pk=pk)
         # Check whether interest indication is currently allowed
         current_timestamp = datetime.now().astimezone(ak.event.timezone)
         if ak_interest_indication_active(ak.event, current_timestamp):
@@ -35,4 +35,5 @@ def increment_interest_counter(request, event_slug, pk, **kwargs):
             ak.save()
             return Response({'interest_counter': ak.interest_counter}, status=status.HTTP_200_OK)
         return Response(status=status.HTTP_403_FORBIDDEN)
-    return Response(status=status.HTTP_404_NOT_FOUND)
+    except AK.DoesNotExist:
+        return Response(status=status.HTTP_404_NOT_FOUND)
diff --git a/AKSubmission/tests.py b/AKSubmission/tests.py
index a39b155ac3ee946fb97efafe6ecbb42f571cd7ad..4cff0830106ccbffcd142edf2940d994257e3148 100644
--- a/AKSubmission/tests.py
+++ b/AKSubmission/tests.py
@@ -1 +1,162 @@
-# Create your tests here.
+from datetime import timedelta
+
+from django.test import TestCase
+from django.urls import reverse_lazy
+from django.utils.datetime_safe import datetime
+
+from AKModel.models import AK, AKSlot, Event
+from AKModel.tests import BasicViewTests
+
+
+class ModelViewTests(BasicViewTests, TestCase):
+    fixtures = ['model.json']
+
+    VIEWS = [
+        ('submission_overview', {'event_slug': 'kif42'}),
+        ('ak_detail', {'event_slug': 'kif42', 'pk': 1}),
+        ('ak_history', {'event_slug': 'kif42', 'pk': 1}),
+        ('ak_edit', {'event_slug': 'kif42', 'pk': 1}),
+        ('akslot_add', {'event_slug': 'kif42', 'pk': 1}),
+        ('akmessage_add', {'event_slug': 'kif42', 'pk': 1}),
+        ('akslot_edit', {'event_slug': 'kif42', 'pk': 5}),
+        ('akslot_delete', {'event_slug': 'kif42', 'pk': 5}),
+        ('ak_list', {'event_slug': 'kif42'}),
+        ('ak_list_by_category', {'event_slug': 'kif42', 'category_pk': 4}),
+        ('ak_list_by_track', {'event_slug': 'kif42', 'track_pk': 1}),
+        ('akowner_create', {'event_slug': 'kif42'}),
+        ('akowner_edit', {'event_slug': 'kif42', 'slug': 'a'}),
+        ('submit_ak', {'event_slug': 'kif42', 'owner_slug': 'a'}),
+        ('submit_ak_wish', {'event_slug': 'kif42'}),
+        ('error_not_configured', {'event_slug': 'kif42'}),
+    ]
+
+    APP_NAME = 'submit'
+
+    def test_akslot_edit_delete_prevention(self):
+        """
+        Slots planned already may not be modified or deleted in front end
+        """
+        self.client.logout()
+
+        view_name_with_prefix, url = self._name_and_url(('akslot_edit', {'event_slug': 'kif42', 'pk': 1}))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 302,
+                         msg=f"AK Slot editing ({url}) possible even though slot was already scheduled")
+        self._assert_message(response, "You cannot edit a slot that has already been scheduled")
+
+        view_name_with_prefix, url = self._name_and_url(('akslot_delete', {'event_slug': 'kif42', 'pk': 1}))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 302,
+                         msg=f"AK Slot deletion ({url}) possible even though slot was already scheduled")
+        self._assert_message(response, "You cannot delete a slot that has already been scheduled")
+
+    def test_slot_creation_deletion(self):
+        ak_args = {'event_slug': 'kif42', 'pk': 1}
+        redirect_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs=ak_args)
+
+        count_slots = AK.objects.get(pk=1).akslot_set.count()
+
+        create_url = reverse_lazy(f"{self.APP_NAME}:akslot_add", kwargs=ak_args)
+        response = self.client.post(create_url, {'ak': 1, 'event': 2, 'duration': 1.5})
+        self.assertRedirects(response, redirect_url, status_code=302, target_status_code=200,
+                             msg_prefix="Did not correctly trigger redirect")
+        self.assertEqual(AK.objects.get(pk=1).akslot_set.count(), count_slots + 1,
+                         msg="New slot was not correctly saved")
+
+        # Get primary key of newly created Slot
+        slot_pk = AK.objects.get(pk=1).akslot_set.order_by('pk').last().pk
+
+        edit_url = reverse_lazy(f"{self.APP_NAME}:akslot_edit", kwargs={'event_slug': 'kif42', 'pk': slot_pk})
+        response = self.client.get(edit_url)
+        self.assertEqual(response.status_code, 200, msg=f"Cant open edit view for newly created slot ({edit_url})")
+        response = self.client.post(edit_url, {'ak': 1, 'event': 2, 'duration': 2})
+        self.assertRedirects(response, redirect_url, status_code=302, target_status_code=200,
+                             msg_prefix="Did not correctly trigger redirect")
+        self.assertEqual(AKSlot.objects.get(pk=slot_pk).duration, 2,
+                         msg="Slot was not correctly changed")
+
+        deletion_url = reverse_lazy(f"{self.APP_NAME}:akslot_delete", kwargs={'event_slug': 'kif42', 'pk': slot_pk})
+        response = self.client.get(deletion_url)
+        self.assertEqual(response.status_code, 200,
+                         msg="Cant open deletion view for newly created slot ({deletion_url})")
+        response = self.client.post(deletion_url, {})
+        self.assertRedirects(response, redirect_url, status_code=302, target_status_code=200,
+                             msg_prefix="Did not correctly trigger redirect")
+        self.assertFalse(AKSlot.objects.filter(pk=slot_pk).exists(), msg="Slot was not correctly deleted")
+        self.assertEqual(AK.objects.get(pk=1).akslot_set.count(), count_slots, msg="AK still has to many slots")
+
+    def test_ak_owner_editing(self):
+        # Test editing of new user
+        edit_url = reverse_lazy(f"{self.APP_NAME}:akowner_edit_dispatch", kwargs={'event_slug': 'kif42'})
+
+        base_url = reverse_lazy(f"{self.APP_NAME}:submission_overview", kwargs={'event_slug': 'kif42'})
+        response = self.client.post(edit_url, {'owner_id': -1})
+        self.assertRedirects(response, base_url, status_code=302, target_status_code=200,
+                             msg_prefix="Did not redirect to start page even though no user was selected")
+        self._assert_message(response, "No user selected")
+
+        edit_redirect_url = reverse_lazy(f"{self.APP_NAME}:akowner_edit", kwargs={'event_slug': 'kif42', 'slug': 'a'})
+        response = self.client.post(edit_url, {'owner_id': 1})
+        self.assertRedirects(response, edit_redirect_url, status_code=302, target_status_code=200,
+                             msg_prefix=f"Dispatch redirect failed (should go to {edit_redirect_url})")
+
+    def test_ak_owner_selection(self):
+        select_url = reverse_lazy(f"{self.APP_NAME}:akowner_select", kwargs={'event_slug': 'kif42'})
+
+        create_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'})
+        response = self.client.post(select_url, {'owner_id': -1})
+        self.assertRedirects(response, create_url, status_code=302, target_status_code=200,
+                             msg_prefix="Did not redirect to user create view even though no user was specified")
+
+        add_redirect_url = reverse_lazy(f"{self.APP_NAME}:submit_ak", kwargs={'event_slug': 'kif42', 'owner_slug': 'a'})
+        response = self.client.post(select_url, {'owner_id': 1})
+        self.assertRedirects(response, add_redirect_url, status_code=302, target_status_code=200,
+                    msg_prefix=f"Dispatch redirect to ak submission page failed (should go to {add_redirect_url})")
+
+    def test_orga_message_submission(self):
+        form_url = reverse_lazy(f"{self.APP_NAME}:akmessage_add", kwargs={'event_slug': 'kif42', 'pk': 1})
+        detail_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs={'event_slug': 'kif42', 'pk': 1})
+
+        count_messages = AK.objects.get(pk=1).akorgamessage_set.count()
+
+        response = self.client.get(form_url)
+        self.assertEqual(response.status_code, 200, msg="Could not load message form view")
+        response = self.client.post(form_url, {'ak': 1, 'event': 2, 'text': 'Test message text'})
+        self.assertRedirects(response, detail_url, status_code=302, target_status_code=200,
+                             msg_prefix=f"Did not trigger redirect to ak detail page ({detail_url})")
+        self._assert_message(response, "Message to organizers successfully saved")
+        self.assertEqual(AK.objects.get(pk=1).akorgamessage_set.count(), count_messages + 1,
+                         msg="Message was not correctly saved")
+
+    def test_interest_api(self):
+        interest_api_url = "/kif42/api/ak/1/indicate-interest/"
+
+        ak = AK.objects.get(pk=1)
+        event = Event.objects.get(slug='kif42')
+        ak_interest_counter = ak.interest_counter
+
+        response = self.client.get(interest_api_url)
+        self.assertEqual(response.status_code, 405, "Should not be accessible via GET")
+
+        event.interest_start = datetime.now().astimezone(event.timezone) + timedelta(minutes=-10)
+        event.interest_end = datetime.now().astimezone(event.timezone) + timedelta(minutes=+10)
+        event.save()
+
+        response = self.client.post(interest_api_url)
+        self.assertEqual(response.status_code, 200, f"API end point not working ({interest_api_url})")
+        self.assertEqual(AK.objects.get(pk=1).interest_counter, ak_interest_counter + 1, "Counter was not increased")
+
+        event.interest_end = datetime.now().astimezone(event.timezone) + timedelta(minutes=-2)
+        event.save()
+
+        response = self.client.post(interest_api_url)
+        self.assertEqual(response.status_code, 403,
+                    "API end point still reachable even though interest indication window ended ({interest_api_url})")
+        self.assertEqual(AK.objects.get(pk=1).interest_counter, ak_interest_counter + 1,
+                         "Counter was increased even though interest indication window ended")
+
+        invalid_interest_api_url = "/kif42/api/ak/-1/indicate-interest/"
+        response = self.client.post(invalid_interest_api_url)
+        self.assertEqual(response.status_code, 404, f"Invalid URL reachable ({interest_api_url})")
+
+