diff --git a/.gitignore b/.gitignore
index 70d2c1202e645fc31260a0fdd6bac90fdd25e15a..a30a3fa095b66e018388937f3ffb79eed4692301 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,3 +74,8 @@ htmlcov/
 maintenance_mode_state.txt
 media/
 package-lock.json
+
+# VSCode
+.vscode/
+.history/
+*.code-workspace
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8a6b9273572106de9983bbd65662014781aa9535..d36458971660c0b5ab354ad1e4b03068fb46ebea 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,24 +1,6 @@
-stages:
-  - test
-  - build
-  - deploy
-
 include:
+    - local: "/ci/general.yml"
     - local: "/ci/test.yml"
     - local: "/ci/build_dist.yml"
     - local: "/ci/build_docker.yml"
     - local: "/ci/deploy.yml"
-
-variables:
-  GIT_SUBMODULE_STRATEGY: recursive
-  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
-  FF_NETWORK_PER_BUILD: "true"
-
-cache:
-  key:
-    files:
-      - poetry.lock
-      - pyproject.toml
-  paths:
-    - .cache/pip
-    - .tox
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 2c24e62bb4d1acd60579eb0064c0a79c08748d7f..be12afc1552dc2b6d6cb398bc2a456392ef21b9d 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -48,7 +48,7 @@ Working with the Git repository
 
 The Git repository shall be used as a historic documentation of development
 and as change management. It is important that the Git commit history
-describes waht was changed, by whom and why.
+describes what was changed, by whom and why.
 
 Help and information on Git for beginners are available in the `Git guide`_
 
diff --git a/aleksis/core/templates/core/crud_events.html b/aleksis/core/templates/core/crud_events.html
index 30c4ec464f29a8e99d7b6dd72c0bc824a9e9825f..d80fed9c93b37f5c9786d416038dbbb139176036 100644
--- a/aleksis/core/templates/core/crud_events.html
+++ b/aleksis/core/templates/core/crud_events.html
@@ -1,31 +1,33 @@
-{% load i18n %}
+{% load i18n data_helpers %}
 
 <ul class="collection">
   {% for event in obj.crud_events %}
     {% if no_m2m and event.event_type == event.M2M_CHANGE or event.event_type == event.M2M_CHANGE_REV %}
     {% else %}
       <li class="collection-item">
-        {% if event.event_type == event.CREATE %}
-          {% blocktrans with person=event.user.person %}
-            Created by {{ person }}
-          {% endblocktrans %}
-        {% elif event.event_type == event.UPDATE %}
-          {% blocktrans with person=event.user.person %}
-            Updated by {{ person }}
-          {% endblocktrans %}
-        {% elif event.event_type == event.DELETE %}
-          {% blocktrans with person=event.user.person %}
-            Deleted by {{ person }}
-          {% endblocktrans %}
-        {% elif event.event_type == event.M2M_CHANGE %}
-          {% blocktrans with person=event.user.person %}
-            Updated by {{ person }}
-          {% endblocktrans %}
-        {% elif event.event_type == event.M2M_CHANGE_REV %}
-          {% blocktrans with person=event.user.person %}
-            Updated by {{ person }}
-          {% endblocktrans %}
-        {% endif %}
+        <strong>
+          {% if event.event_type == event.CREATE %}
+            {% blocktrans with person=event.user.person %}
+              Created by {{ person }}
+            {% endblocktrans %}
+          {% elif event.event_type == event.UPDATE %}
+            {% blocktrans with person=event.user.person %}
+              Updated by {{ person }}
+            {% endblocktrans %}
+          {% elif event.event_type == event.DELETE %}
+            {% blocktrans with person=event.user.person %}
+              Deleted by {{ person }}
+            {% endblocktrans %}
+          {% elif event.event_type == event.M2M_CHANGE %}
+            {% blocktrans with person=event.user.person %}
+              Updated by {{ person }}
+            {% endblocktrans %}
+          {% elif event.event_type == event.M2M_CHANGE_REV %}
+            {% blocktrans with person=event.user.person %}
+              Updated by {{ person }}
+            {% endblocktrans %}
+          {% endif %}
+        </strong>
 
         <div class="left" style="margin-right: 10px;">
           {% if event.event_type == event.CREATE %}
@@ -43,6 +45,17 @@
         <div class="right">
           {{ event.datetime }}
         </div>
+        {% parse_json event.changed_fields as changed_fields %}
+        {% if changed_fields %}
+          <ul>
+            {% for field, change in changed_fields.items %}
+              {% verbose_name "homework" "assignment" field as verbose_name %}
+              <li>
+                {{ verbose_name }}: <s>{{ change.0 }}</s> → {{ change.1 }}
+              </li>
+            {% endfor %}
+          </ul>
+        {% endif %}
       </li>
     {% endif %}
   {% endfor %}
diff --git a/aleksis/core/templates/templated_email/notification.email b/aleksis/core/templates/templated_email/notification.email
index bb39f5861876b1dc1c4c39639de649ec6d6590eb..8e61fd0df28b5bc6342c891921831e147e9cc8d9 100644
--- a/aleksis/core/templates/templated_email/notification.email
+++ b/aleksis/core/templates/templated_email/notification.email
@@ -2,21 +2,48 @@
 
 {% block subject %} {% trans "New notification for" %} {{ notification_user }} {% endblock %}
 
+{% block plain %}
+    {% blocktrans with notification_user=notification_user %}Dear {{ notification_user }},{% endblocktrans %}
+
+    {% trans "we got a new notification for you:" %}
+
+    {{ notification.title }}
+
+    {{ notification.description }}
+
+    {% if notification.link %}
+        {% trans "More information" %} → {{ notification.link }}
+    {% endif %}
+
+    {% blocktrans with trans_sender=notification.sender trans_created_at=notification.created_at %}
+        Sent by {{ trans_sender }} at {{ trans_created_at }}
+    {% endblocktrans %}
+
+    {% trans "Your AlekSIS team" %}
+{% endblock %}
+
 {% block html %}
 <main>
-    <p>{% trans "Dear" %} {{ notification_user }}, <br>
-        {% trans "we got a new notification for you:" %}</p>
+    <p>{% blocktrans with notification_user=notification_user %}Dear {{ notification_user }},{% endblocktrans %}</p>
+
+    <p>{% trans "we got a new notification for you:" %}</p>
+
     <blockquote>
+        <h5>{{ notification.title }}</h5>
         <p>{{ notification.description }}</p>
         {% if notification.link %}
             <a href="{{ notification.link }}">{% trans "More information" %} →</a>
         {% endif %}
     </blockquote>
 
-    {% blocktrans with trans_sender=notification.sender trans_created_at=notification.created_at %}
-    <p>By {{ trans_sender }} at {{ trans_created_at }}</p>
+    <p>
+        {% blocktrans with trans_sender=notification.sender trans_created_at=notification.created_at %}
+            Sent by {{ trans_sender }} at {{ trans_created_at }}
+        {% endblocktrans %}
+    </p>
 
-    <i>Your AlekSIS team</i>
-    {% endblocktrans %}
+    <p>
+        <i>{% trans "Your AlekSIS team" %}</i>
+    </p>
 </main>
 {% endblock %}
diff --git a/aleksis/core/templatetags/data_helpers.py b/aleksis/core/templatetags/data_helpers.py
index 19f286434db34eb9e74b88950b8d87ee854959ef..f7393c73fd86eba6f861f87e321bf8dc5cbce6fd 100644
--- a/aleksis/core/templatetags/data_helpers.py
+++ b/aleksis/core/templatetags/data_helpers.py
@@ -1,6 +1,8 @@
-from typing import Any
+import json
+from typing import Any, Optional, Union
 
 from django import template
+from django.contrib.contenttypes.models import ContentType
 
 register = template.Library()
 
@@ -16,3 +18,24 @@ def get_dict(value: Any, arg: Any) -> Any:
         return value[int(arg)]
     else:
         return None
+
+
+@register.simple_tag
+def verbose_name(app_label: str, model: str, field: Optional[str] = None) -> str:
+    """Get a verbose name of a model or a field by app label and model name."""
+    ct = ContentType.objects.get(app_label=app_label, model=model).model_class()
+
+    if field:
+        # Field
+        return ct._meta.get_field(field).verbose_name.title()
+    else:
+        # Whole model
+        return ct._meta.verbose_name.title()
+
+
+@register.simple_tag
+def parse_json(value: Optional[str] = None) -> Union[dict, None]:
+    """Template tag for parsing JSON from a string."""
+    if not value:
+        return None
+    return json.loads(value)
diff --git a/aleksis/core/tests/models/test_notification.py b/aleksis/core/tests/models/test_notification.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b1a6df054fe24f06bd363e825df3f1259af53e8
--- /dev/null
+++ b/aleksis/core/tests/models/test_notification.py
@@ -0,0 +1,34 @@
+import pytest
+
+from aleksis.core.models import Notification, Person
+
+pytestmark = pytest.mark.django_db
+
+
+def test_email_notification(mailoutbox):
+    email = "doe@example.com"
+    recipient = Person.objects.create(first_name="Jane", last_name="Doe", email=email)
+
+    sender = "Foo"
+    title = "There is happened something."
+    description = "Here you get some more information."
+    link = "https://aleksis.org/"
+
+    notification = Notification(
+        sender=sender, recipient=recipient, title=title, description=description, link=link
+    )
+    notification.save()
+
+    assert notification.sent
+
+    assert len(mailoutbox) == 1
+
+    mail = mailoutbox[0]
+
+    assert email in mail.to
+    assert title in mail.body
+    assert description in mail.body
+    assert link in mail.body
+    assert sender in mail.body
+    assert recipient.addressing_name in mail.subject
+    assert recipient.addressing_name in mail.body
diff --git a/aleksis/core/tests/templatetags/test_data_helpers.py b/aleksis/core/tests/templatetags/test_data_helpers.py
index ce43e578dbd84f95ed96a6cf1426d615f7e9c816..176e08b22ecb252d5b0113583ab534f46e978072 100644
--- a/aleksis/core/tests/templatetags/test_data_helpers.py
+++ b/aleksis/core/tests/templatetags/test_data_helpers.py
@@ -1,4 +1,10 @@
-from aleksis.core.templatetags.data_helpers import get_dict
+import json
+
+import pytest
+
+from aleksis.core.templatetags.data_helpers import get_dict, parse_json, verbose_name
+
+pytestmark = pytest.mark.django_db
 
 
 def test_get_dict_object():
@@ -24,3 +30,24 @@ def test_get_dict_invalid():
     _foo = 12
 
     assert get_dict(_foo, "bar") is None
+
+
+def test_verbose_name_model():
+    assert verbose_name("core", "person") == "Person"
+
+
+def test_verbose_name_field():
+    assert verbose_name("core", "person", "first_name") == "First Name"
+
+
+def test_parse_json_json():
+    foo = {"foo": 12, "bar": "12", "baz": []}
+    foo_json = json.dumps(foo)
+
+    assert parse_json(foo_json) == foo
+    assert parse_json("{}") == {}
+
+
+def test_parse_json_empty():
+    assert parse_json(None) is None
+    assert parse_json("") is None
diff --git a/aleksis/core/util/notifications.py b/aleksis/core/util/notifications.py
index c9cbcb9f76700d3e26f9c448a5e02c5e103a4936..38d27057033752d6ae7cdefce9c53b4728447b80 100644
--- a/aleksis/core/util/notifications.py
+++ b/aleksis/core/util/notifications.py
@@ -73,13 +73,13 @@ def send_notification(notification: Union[int, "Notification"], resend: bool = F
     If resend is passed as True, the notification is sent even if it was
     previously marked as sent.
     """
-    channels = lazy_preference("notification", "channels")
-
     if isinstance(notification, int):
-        notification = apps.get_model("core", "Notification")
-        notification_ = notification.objects.get(pk=notification)
+        Notification = apps.get_model("core", "Notification")
+        notification = Notification.objects.get(pk=notification)
+
+    channels = [notification.recipient.preferences["notification__channels"]]
 
-    if resend or not notification_.sent:
+    if resend or not notification.sent:
         for channel in channels:
             name, check, send = _CHANNELS_MAP[channel]
             if check():
diff --git a/aleksis/core/util/predicates.py b/aleksis/core/util/predicates.py
index 0e452ddb0cb560ba4edbe174c560ade56c6261ed..975273d7d934ae41df8ecefb494b121ed69b2a94 100644
--- a/aleksis/core/util/predicates.py
+++ b/aleksis/core/util/predicates.py
@@ -98,6 +98,12 @@ def is_group_owner(user: User, group: Group) -> bool:
     return group.owners.filter(owners=user.person).exists()
 
 
+@predicate
+def is_group_member(user: User, group: Group) -> bool:
+    """Predicate which checks if the user is a member of the provided group."""
+    return user.person in group.members.all()
+
+
 @predicate
 def is_notification_recipient(user: User, obj: Model) -> bool:
     """Check if is a notification recipient.
diff --git a/ci/build_docker.yml b/ci/build_docker.yml
index 83955938ce3f756d7947d18515cdf469a7bd92d8..92a1572c6ac4d1878110500f458262a6a7c5bdf4 100644
--- a/ci/build_docker.yml
+++ b/ci/build_docker.yml
@@ -1,9 +1,7 @@
-image: registry.edugit.org/teckids/team-sysadmin/docker-images/python-pimped:master
-
 build_docker:
   stage: build
   image:
-    name: gcr.io/kaniko-project/executor:v0.22.0
+    name: gcr.io/kaniko-project/executor:debug
     entrypoint: [""]
   script:
     - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" >/kaniko/.docker/config.json
diff --git a/ci/general.yml b/ci/general.yml
new file mode 100644
index 0000000000000000000000000000000000000000..58891983dbeda6b9c8f49dc07e490a021a4c31dd
--- /dev/null
+++ b/ci/general.yml
@@ -0,0 +1,20 @@
+image: registry.edugit.org/teckids/team-sysadmin/docker-images/python-pimped:latest
+
+stages:
+  - test
+  - build
+  - deploy
+
+variables:
+  GIT_SUBMODULE_STRATEGY: recursive
+  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
+  FF_NETWORK_PER_BUILD: "true"
+
+cache:
+  key:
+    files:
+      - poetry.lock
+      - pyproject.toml
+  paths:
+    - .cache/pip
+    - .tox
diff --git a/poetry.lock b/poetry.lock
index cbd4fbb8216f54319da1281ed0cab5e2a3a1fb6c..0ead8c370fec4f3505f48da1f8af8bafe536ffd4 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -665,7 +665,7 @@ description = "A pluggable framework for adding two-factor authentication to Dja
 name = "django-otp"
 optional = false
 python-versions = "*"
-version = "0.9.0"
+version = "0.9.1"
 
 [package.dependencies]
 django = ">=1.11"
@@ -698,10 +698,6 @@ version = "3.0.1"
 Django = ">=1.11.3"
 babel = "*"
 
-[package.dependencies.phonenumbers]
-optional = true
-version = ">=7.0.2"
-
 [package.extras]
 phonenumbers = ["phonenumbers (>=7.0.2)"]
 phonenumberslite = ["phonenumberslite (>=7.0.2)"]
@@ -1003,10 +999,10 @@ description = "flake8 plugin to call black as a code style validator"
 name = "flake8-black"
 optional = false
 python-versions = "*"
-version = "0.1.2"
+version = "0.2.0"
 
 [package.dependencies]
-black = ">=19.3b0"
+black = "*"
 flake8 = ">=3.0.0"
 
 [[package]]
@@ -1792,7 +1788,7 @@ description = "A simple tool/library for working with SPDX license definitions."
 name = "spdx-license-list"
 optional = false
 python-versions = "*"
-version = "0.4.0"
+version = "0.5.0"
 
 [[package]]
 category = "dev"
@@ -2114,7 +2110,7 @@ celery = ["Celery", "django-celery-results", "django-celery-beat", "django-celer
 ldap = ["django-auth-ldap"]
 
 [metadata]
-content-hash = "4ed9748bdca432a11eca81cfeccd2c141b3d3d895c8382a70bc79c622fd023d6"
+content-hash = "3e85d3bfff56719272c13ad591ca4ccd7a2b72324a33c2192be383536b66b5f8"
 python-versions = "^3.7"
 
 [metadata.files]
@@ -2365,8 +2361,8 @@ django-middleware-global-request = [
     {file = "django-middleware-global-request-0.1.2.tar.gz", hash = "sha256:f6490759bc9f7dbde4001709554e29ca715daf847f2222914b4e47117dca9313"},
 ]
 django-otp = [
-    {file = "django-otp-0.9.0.tar.gz", hash = "sha256:f5faa95a3e85391e70e433205509fa070ed25646f15fcafd2cd2fbd987c33262"},
-    {file = "django_otp-0.9.0-py3-none-any.whl", hash = "sha256:334e2a0ece7e5d9de3263e17bd3b6aee2809d1f8d70555408d5bf8f0c33b13fb"},
+    {file = "django-otp-0.9.1.tar.gz", hash = "sha256:f456639addace8b6d1eb77f9edaada1a53dbb4d6f3c19f17c476c4e3e4beb73f"},
+    {file = "django_otp-0.9.1-py3-none-any.whl", hash = "sha256:0c67cf6f4bd6fca84027879ace9049309213b6ac81f88e954376a6b5535d96c4"},
 ]
 django-otp-yubikey = [
     {file = "django-otp-yubikey-0.5.2.tar.gz", hash = "sha256:f0b1881562fb42ee9f12c28d284cbdb90d1f0383f2d53a595373b080a19bc261"},
@@ -2454,7 +2450,7 @@ flake8-bandit = [
     {file = "flake8_bandit-2.1.2.tar.gz", hash = "sha256:687fc8da2e4a239b206af2e54a90093572a60d0954f3054e23690739b0b0de3b"},
 ]
 flake8-black = [
-    {file = "flake8-black-0.1.2.tar.gz", hash = "sha256:b79d8d868bd42dc2c1f27469b92a984ecab3579ad285a8708ea5f19bf6c1f3a2"},
+    {file = "flake8-black-0.2.0.tar.gz", hash = "sha256:10e7ff9f81f637a9471684e5624d6a32b11cba362b38df4e20fc8f761184440b"},
 ]
 flake8-builtins = [
     {file = "flake8-builtins-1.5.3.tar.gz", hash = "sha256:09998853b2405e98e61d2ff3027c47033adbdc17f9fe44ca58443d876eb00f3b"},
@@ -2889,8 +2885,8 @@ soupsieve = [
     {file = "soupsieve-1.9.6.tar.gz", hash = "sha256:7985bacc98c34923a439967c1a602dc4f1e15f923b6fcf02344184f86cc7efaa"},
 ]
 spdx-license-list = [
-    {file = "spdx_license_list-0.4.0-py3-none-any.whl", hash = "sha256:e5c2d1efc4067ff83609a200c731db6c656fdfd26144ac8b50755d6c72515453"},
-    {file = "spdx_license_list-0.4.0.tar.gz", hash = "sha256:f8b5eeda2a1c88d8ce15f6324d5a6128a462932a2e55b032f017ac9a0e61f1c7"},
+    {file = "spdx_license_list-0.5.0-py3-none-any.whl", hash = "sha256:65c9f598dee3249d529300eb08800f8bf3d0d902868669146ada65192ecd0507"},
+    {file = "spdx_license_list-0.5.0.tar.gz", hash = "sha256:40cd53ff16401bab7059e6d1ef61839196b12079929a2763a50145d3b6949bc1"},
 ]
 sphinx = [
     {file = "Sphinx-3.0.3-py3-none-any.whl", hash = "sha256:f5505d74cf9592f3b997380f9bdb2d2d0320ed74dd69691e3ee0644b956b8d83"},
diff --git a/pyproject.toml b/pyproject.toml
index 103d35de097f7e1afe2c862ae62937be0d976d72..47ff4e661cf5237fb6f2245f84de728f97ff2b0d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -72,7 +72,7 @@ django-celery-beat = {version="^2.0.0", optional=true}
 django-celery-email = {version="^3.0.0", optional=true}
 django-jsonstore = "^0.4.1"
 django-polymorphic = "^2.1.2"
-django-otp = "0.9.0"
+django-otp = "0.9.1"
 django-colorfield = "^0.3.0"
 django-bleach = "^0.6.1"
 django-guardian = "^2.2.0"
@@ -81,7 +81,7 @@ django-cache-memoize = "^0.1.6"
 django-haystack = {version="3.0b1", allow-prereleases = true}
 celery-haystack = {version="^0.10.0", optional=true}
 django-dbbackup = "^3.3.0"
-spdx-license-list = "^0.4.0"
+spdx-license-list = "^0.5.0"
 license-expression = "^1.2"
 django-reversion = "^3.0.7"
 django-favicon-plus-reloaded = "^1.0.4"
@@ -109,7 +109,7 @@ flake8-builtins = "^1.4.1"
 flake8-docstrings = "^1.5.0"
 flake8-rst-docstrings = "^0.0.13"
 black = "^19.10b0"
-flake8-black = "^0.1.1"
+flake8-black = "^0.2.0"
 isort = "^4.3.21"
 flake8-isort = "^3.0.0"
 pytest-cov = "^2.8.1"