diff --git a/aleksis/apps/alsijil/data_checks.py b/aleksis/apps/alsijil/data_checks.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3e3edd1cff1900e4a404c7894efe25e322f85b9
--- /dev/null
+++ b/aleksis/apps/alsijil/data_checks.py
@@ -0,0 +1,96 @@
+import logging
+
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import F
+from django.utils.translation import gettext as _
+
+import reversion
+from calendarweek import CalendarWeek
+
+
+class SolveOption:
+    name: str = "default"
+    verbose_name: str = ""
+
+    @classmethod
+    def solve(cls, check_result: "DataCheckResult"):
+        pass
+
+
+class IgnoreSolveOption(SolveOption):
+    name = "ignore"
+    verbose_name = _("Ignore problem")
+
+    @classmethod
+    def solve(cls, check_result: "DataCheckResult"):
+        check_result.solved = True
+        check_result.save()
+
+
+class DataCheck:
+    name: str = ""
+    verbose_name: str = ""
+    problem_name: str = ""
+
+    solve_options = {IgnoreSolveOption.name: IgnoreSolveOption}
+
+    @classmethod
+    def check_data(cls):
+        pass
+
+    @classmethod
+    def solve(cls, check_result: "DataCheckResult", solve_option: str = "default"):
+        with reversion.create_revision():
+            cls.solve_options[solve_option].solve(check_result)
+
+
+class DeleteRelatedObjectSolveOption(SolveOption):
+    name = "delete"
+    verbose_name = _("Delete object")
+
+    @classmethod
+    def solve(cls, check_result: "DataCheckResult"):
+        check_result.related_object.delete()
+        check_result.delete()
+
+
+class NoPersonalNotesInCancelledLessonsDataCheck(DataCheck):
+    name = "no_personal_notes_in_cancelled_lessons"
+    verbose_name = _("Ensure that there are no personal notes in cancelled lessons")
+    problem_name = _("The personal note is related to a cancelled lesson.")
+    solve_options = {
+        DeleteRelatedObjectSolveOption.name: DeleteRelatedObjectSolveOption,
+        IgnoreSolveOption.name: IgnoreSolveOption,
+    }
+
+    @classmethod
+    def check_data(cls):
+        from .models import PersonalNote, DataCheckResult
+
+        ct = ContentType.objects.get_for_model(PersonalNote)
+
+        personal_notes = PersonalNote.objects.filter(
+            lesson_period__substitutions__cancelled=True,
+            lesson_period__substitutions__week=F("week"),
+            lesson_period__substitutions__year=F("year"),
+        ).prefetch_related("lesson_period", "lesson_period__substitutions")
+
+        for note in personal_notes:
+            logging.info(f"Check personal note {note}")
+            sub = note.lesson_period.get_substitution(
+                CalendarWeek(week=note.week, year=note.year)
+            )
+            result = DataCheckResult.objects.get_or_create(
+                check=cls.name, content_type=ct, object_id=note.id
+            )
+
+
+DATA_CHECKS = [NoPersonalNotesInCancelledLessonsDataCheck]
+DATA_CHECKS_BY_NAME = {check.name: check for check in DATA_CHECKS}
+DATA_CHECKS_CHOICES = [(check.name, check.verbose_name) for check in DATA_CHECKS]
+
+
+def check_data():
+    for check in DATA_CHECKS:
+        logging.info(f"Run check: {check.verbose_name}")
+        check.check_data()
diff --git a/aleksis/apps/alsijil/menus.py b/aleksis/apps/alsijil/menus.py
index 8b51bb206862364dbed6c57ff9af25d598b48e33..45ca37a2d51b62e11a08ebb169ec653a2c5df166 100644
--- a/aleksis/apps/alsijil/menus.py
+++ b/aleksis/apps/alsijil/menus.py
@@ -60,6 +60,12 @@ MENUS = {
                     "icon": "label",
                     "validators": ["menu_generator.validators.is_superuser"],
                 },
+                {
+                    "name": _("Check data"),
+                    "url": "check_data",
+                    "icon": "label",
+                    "validators": ["menu_generator.validators.is_superuser"],
+                },
             ],
         }
     ]
diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py
index ea951fd97a4a7676d775070440a77e8b1b68783e..b584083b4b0842610f4503a59cd15d72cac531ea 100644
--- a/aleksis/apps/alsijil/models.py
+++ b/aleksis/apps/alsijil/models.py
@@ -1,9 +1,17 @@
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
 from django.db import models
+from django.urls import reverse
 from django.utils.formats import date_format
 from django.utils.translation import gettext_lazy as _
 
 from calendarweek import CalendarWeek
 
+from aleksis.apps.alsijil.data_checks import (
+    DATA_CHECKS_BY_NAME,
+    DATA_CHECKS_CHOICES,
+    DataCheck,
+)
 from aleksis.apps.chronos.mixins import WeekRelatedMixin
 from aleksis.apps.chronos.models import LessonPeriod
 from aleksis.apps.chronos.util.date import get_current_year
@@ -102,6 +110,15 @@ class PersonalNote(ExtensibleModel, WeekRelatedMixin):
     def __str__(self):
         return f"{date_format(self.date)}, {self.lesson_period}, {self.person}"
 
+    def get_absolute_url(self):
+        return (
+            reverse(
+                "lesson_by_week_and_period",
+                args=[self.year, self.week, self.lesson_period.pk],
+            )
+            + "#personal-notes"
+        )
+
     class Meta:
         verbose_name = _("Personal note")
         verbose_name_plural = _("Personal notes")
@@ -208,3 +225,28 @@ class ExtraMark(ExtensibleModel):
         ordering = ["short_name"]
         verbose_name = _("Extra mark")
         verbose_name_plural = _("Extra marks")
+
+
+class DataCheckResult(ExtensibleModel):
+    check = models.CharField(
+        max_length=255,
+        verbose_name=_("Related data check task"),
+        choices=DATA_CHECKS_CHOICES,
+    )
+
+    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+    object_id = models.CharField(max_length=255)
+    related_object = GenericForeignKey("content_type", "object_id")
+
+    solved = models.BooleanField(default=False, verbose_name=_("Issue solved"))
+
+    @property
+    def related_check(self) -> DataCheck:
+        return DATA_CHECKS_BY_NAME[self.check]
+
+    def solve(self, solve_option: str = "default"):
+        self.related_check.solve(self, solve_option)
+
+    class Meta:
+        verbose_name = _("Data check result")
+        verbose_name_plural = _("Data check results")
diff --git a/aleksis/apps/alsijil/templates/alsijil/data_check/list.html b/aleksis/apps/alsijil/templates/alsijil/data_check/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..3d84d259528f6f11923d2400d0d871079a29d1e6
--- /dev/null
+++ b/aleksis/apps/alsijil/templates/alsijil/data_check/list.html
@@ -0,0 +1,76 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+{% load data_helpers %}
+
+{% load i18n %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Data checks{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Data checks{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <a class="btn green waves-effect waves-light" href="#">
+    <i class="material-icons left">refresh</i>
+    {% trans "Check data again" %}
+  </a>
+
+  {% if results %}
+    <div class="card">
+      <div class="card-content">
+        <i class="material-icons left medium red-text">warning</i>
+        <span class="card-title">{% trans "The system detected some problems with your data." %}</span>
+        <p>{% blocktrans %}Please go through all data and check whether some extra action is
+          needed.{% endblocktrans %}</p>
+      </div>
+    </div>
+  {% else %}
+    <div class="card">
+      <div class="card-content">
+        <i class="material-icons left medium green-text">check_circle</i>
+        <span class="card-title">{% trans "Everything is fine." %}</span>
+        <p>{% blocktrans %}The system hasn't detected any problems with your data.{% endblocktrans %}</p>
+      </div>
+    </div>
+  {% endif %}
+
+  {% if results %}
+    <table>
+      <thead>
+      <tr>
+        <th></th>
+        <th colspan="2">{% trans "Affected object" %}</th>
+        <th>{% trans "Detected problem" %}</th>
+        <th>{% trans "Show details" %}</th>
+        <th>{% trans "Options to solve the problem" %}</th>
+      </tr>
+      </thead>
+      <tbody>
+      {% for result in results %}
+        <tr>
+          <td>
+            <code>{{ result.id }}</code>
+          </td>
+          <td>{% verbose_name_object result.related_object %}</td>
+          <td>{{ result.related_object }}</td>
+
+
+          <td>{{ result.related_check.problem_name }}</td>
+          <td>
+            <a class="btn-flat waves-effect waves-light" href="{{ result.related_object.get_absolute_url }}">
+              {% trans "Show object" %}
+            </a>
+          </td>
+          <td>
+            {% for option_name, option in result.related_check.solve_options.items %}
+              <a class="btn waves-effect waves-light" href="{% url "data_check_solve" result.pk option_name %}">
+                {{ option.verbose_name }}
+              </a>
+            {% endfor %}
+          </td>
+        </tr>
+      {% endfor %}
+      </tbody>
+    </table>
+  {% endif %}
+{% endblock %}
diff --git a/aleksis/apps/alsijil/urls.py b/aleksis/apps/alsijil/urls.py
index af972edd5be09307c83ad550fa2385fe4d088669..1fab7d4e76210ac1dd094cc06382ff061d0924d0 100644
--- a/aleksis/apps/alsijil/urls.py
+++ b/aleksis/apps/alsijil/urls.py
@@ -68,4 +68,10 @@ urlpatterns = [
         views.ExcuseTypeDeleteView.as_view(),
         name="delete_excuse_type",
     ),
+    path("data_check/", views.DataCheckView.as_view(), name="check_data",),
+    path(
+        "data_check/<int:id_>/<str:solve_option>/",
+        views.solve_data_check_view,
+        name="data_check_solve",
+    ),
 ]
diff --git a/aleksis/apps/alsijil/views.py b/aleksis/apps/alsijil/views.py
index b6b76985ecfc6a9cfc4495e8950a721058751dda..875207aaaaae85f5e184da0360cbd0e912a6f4e4 100644
--- a/aleksis/apps/alsijil/views.py
+++ b/aleksis/apps/alsijil/views.py
@@ -1,13 +1,13 @@
 from datetime import date, datetime, timedelta
-from typing import Optional
+from typing import Any, Dict, Optional
 
 from django.core.exceptions import PermissionDenied
-from django.db.models import Count, Exists, OuterRef, Q, Subquery, Sum
+from django.db.models import Count, Exists, OuterRef, Q, QuerySet, Subquery, Sum
 from django.http import Http404, HttpRequest, HttpResponse, HttpResponseNotFound
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse, reverse_lazy
 from django.utils.translation import ugettext as _
-from django.views.generic import DetailView
+from django.views.generic import DetailView, ListView
 
 import reversion
 from calendarweek import CalendarWeek
@@ -15,6 +15,7 @@ from django_tables2 import SingleTableView
 from reversion.views import RevisionMixin
 from rules.contrib.views import PermissionRequiredMixin
 
+from aleksis.apps.alsijil.data_checks import check_data
 from aleksis.apps.chronos.managers import TimetableType
 from aleksis.apps.chronos.models import LessonPeriod, TimePeriod
 from aleksis.apps.chronos.util.chronos_helpers import get_el_by_pk
@@ -32,7 +33,13 @@ from .forms import (
     RegisterAbsenceForm,
     SelectForm,
 )
-from .models import ExcuseType, ExtraMark, LessonDocumentation, PersonalNote
+from .models import (
+    DataCheckResult,
+    ExcuseType,
+    ExtraMark,
+    LessonDocumentation,
+    PersonalNote,
+)
 from .tables import ExcuseTypeTable, ExtraMarkTable
 
 
@@ -751,3 +758,36 @@ class ExcuseTypeDeleteView(AdvancedDeleteView, PermissionRequiredMixin, Revision
     template_name = "core/pages/delete.html"
     success_url = reverse_lazy("excuse_types")
     success_message = _("The excuse type has been deleted.")
+
+
+class DataCheckView(ListView):
+    model = DataCheckResult
+    template_name = "alsijil/data_check/list.html"
+    context_object_name = "results"
+
+    def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+        context = super().get_context_data(**kwargs)
+        check_data()
+        return context
+
+    def get_queryset(self) -> QuerySet:
+        return DataCheckResult.objects.filter(solved=False).order_by("check")
+
+
+def solve_data_check_view(
+    request: HttpRequest, id_: int, solve_option: str = "default"
+):
+    result = get_object_or_404(DataCheckResult, pk=id_)
+    if solve_option in result.related_check.solve_options:
+        solve_option_obj = result.related_check.solve_options[solve_option]
+
+        msg = _(
+            f"The solve option '{solve_option_obj.verbose_name}' has been affected on the object '{result.related_object}' (type: {result.related_object._meta.verbose_name})."
+        )
+
+        result.solve(solve_option)
+
+        messages.success(request, msg)
+        return redirect("check_data")
+    else:
+        return HttpResponseNotFound()