diff --git a/greg/api/filters.py b/greg/api/filters.py
index 307fe3202146880b9c943e485356ac6e95558476..c7af546c6b456316345e0f4977fc2a1675cec7e9 100644
--- a/greg/api/filters.py
+++ b/greg/api/filters.py
@@ -1,21 +1,35 @@
-from django_filters import rest_framework as filters
+from django_filters.rest_framework import (
+    BaseInFilter,
+    BooleanFilter,
+    FilterSet,
+)
 
-from greg.models import Person, PersonRole
+from greg.models import (
+    Person,
+    PersonRole,
+    PersonIdentity,
+)
 
 
-class PersonRoleFilter(filters.FilterSet):
-    type = filters.BaseInFilter(field_name="role__type", lookup_expr="in")
+class PersonRoleFilter(FilterSet):
+    type = BaseInFilter(field_name="role__type", lookup_expr="in")
 
     class Meta:
         model = PersonRole
         fields = ["type"]
 
 
-class PersonFilter(filters.FilterSet):
-    verified = filters.BooleanFilter(
-        field_name="person__verified_by_id", lookup_expr="isnull", exclude=True
+class PersonFilter(FilterSet):
+    verified = BooleanFilter(
+        field_name="identities__verified_by_id", lookup_expr="isnull", exclude=True
     )
 
     class Meta:
         model = Person
         fields = ["first_name", "last_name", "verified"]
+
+
+class PersonIdentityFilter(FilterSet):
+    class Meta:
+        model = PersonIdentity
+        fields = ["type", "verified_by_id"]
diff --git a/greg/api/serializers/person.py b/greg/api/serializers/person.py
index 77eec13afec9c235552e84b503b085ee80c44a77..5a408eaf5b2f7958e2d9d8e00858ee69cb5c83dd 100644
--- a/greg/api/serializers/person.py
+++ b/greg/api/serializers/person.py
@@ -1,6 +1,6 @@
 from rest_framework import serializers
 
-from greg.models import Person, PersonRole, Role
+from greg.models import Person, PersonRole, Role, PersonIdentity
 
 
 class PersonSerializer(serializers.ModelSerializer):
@@ -13,6 +13,11 @@ class PersonSerializer(serializers.ModelSerializer):
             "date_of_birth",
             "email",
             "mobile_phone",
+            "email_verified_date",
+            "mobile_phone",
+            "mobile_phone_verified_date",
+            "registration_completed_date",
+            "token",
         ]
 
 
@@ -31,3 +36,24 @@ class PersonRoleSerializer(serializers.ModelSerializer):
             "updated",
             "role",
         ]
+
+
+class PersonIdentitySerializer(serializers.ModelSerializer):
+    class Meta:
+        model = PersonIdentity
+        fields = "__all__"
+
+    def is_duplicate(self, identity_type: str, value: str) -> bool:
+        # Guests may be verified using another unrecognised identification method,
+        # which the sponsor is required to elaborate in the value column.
+        # In this case we cannot assume the union of the identity type and
+        # the value to be unique across all records.
+        if identity_type == PersonIdentity.IdentityType.OTHER:
+            return False
+
+        # If the type is a specific ID type, then duplicates are not expected
+        return (
+            PersonIdentity.objects.filter(type__like=identity_type)
+            .filter(value__like=value)
+            .exists()
+        )
diff --git a/greg/api/serializers/sponsor.py b/greg/api/serializers/sponsor.py
index 57326558e9999d307412bed70d79665ae63c8821..71f3655c9115346a9cf7be5d280f5496a0543d6b 100644
--- a/greg/api/serializers/sponsor.py
+++ b/greg/api/serializers/sponsor.py
@@ -6,4 +6,4 @@ from greg.models import Sponsor
 class SponsorSerializer(serializers.ModelSerializer):
     class Meta:
         model = Sponsor
-        fields = ["id", "feide_id"]
+        fields = ["id", "feide_id", "first_name", "last_name"]
diff --git a/greg/api/urls.py b/greg/api/urls.py
index 69743c28704421b8128bbe25b19f66d3e7412a42..6428641d97d1370684bb8c48b6c231bf16bd2ae8 100644
--- a/greg/api/urls.py
+++ b/greg/api/urls.py
@@ -1,22 +1,17 @@
 from django.urls import (
-    path,
     re_path,
 )
 from rest_framework.routers import DefaultRouter
-from drf_spectacular.views import (
-    SpectacularAPIView,
-    SpectacularSwaggerView,
-)
 
 from greg.api.views.consent import ConsentViewSet
 from greg.api.views.organizational_unit import OrganizationalUnitViewSet
 from greg.api.views.person import (
     PersonRoleViewSet,
     PersonViewSet,
+    PersonIdentityViewSet,
 )
 from greg.api.views.role import RoleViewSet
-from greg.api.views.health import Health
-from greg.api.views.sponsor import SponsorViewSet
+from greg.api.views.sponsor import SponsorViewSet, SponsorGuestsViewSet
 
 router = DefaultRouter()
 router.register(r"persons", PersonViewSet, basename="person")
@@ -29,13 +24,6 @@ router.register(r"orgunit", OrganizationalUnitViewSet, basename="orgunit")
 urlpatterns = router.urls
 
 urlpatterns += [
-    path("schema/", SpectacularAPIView.as_view(), name="schema"),
-    path(
-        "schema/swagger-ui/",
-        SpectacularSwaggerView.as_view(url_name="schema"),
-        name="swagger-ui",
-    ),
-    path("health/", Health.as_view()),
     re_path(
         r"^persons/(?P<person_id>[0-9]+)/roles/$",
         PersonRoleViewSet.as_view({"get": "list", "post": "create"}),
@@ -43,7 +31,26 @@ urlpatterns += [
     ),
     re_path(
         r"^persons/(?P<person_id>[0-9]+)/roles/(?P<id>[0-9]+)/$",
-        PersonRoleViewSet.as_view({"get": "retrieve"}),
+        PersonRoleViewSet.as_view(
+            {"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
+        ),
         name="person_role-detail",
     ),
+    re_path(
+        r"^persons/(?P<person_id>[0-9]+)/identities/$",
+        PersonIdentityViewSet.as_view({"get": "list", "post": "create"}),
+        name="person_identity-list",
+    ),
+    re_path(
+        r"^persons/(?P<person_id>[0-9]+)/identities/(?P<id>[0-9]+)$",
+        PersonIdentityViewSet.as_view(
+            {"get": "retrieve", "delete": "destroy", "patch": "partial_update"}
+        ),
+        name="person_identity-detail",
+    ),
+    re_path(
+        r"^sponsors/(?P<sponsor_id>[0-9]+)/guests/$",
+        SponsorGuestsViewSet.as_view({"get": "list"}),
+        name="sponsor_guests-list",
+    ),
 ]
diff --git a/greg/api/views/person.py b/greg/api/views/person.py
index 3566e930e53ba46bc3f6682d0b59eddbc7e30632..bb58842992f4afc293d90a6f7457388046579382 100644
--- a/greg/api/views/person.py
+++ b/greg/api/views/person.py
@@ -1,12 +1,17 @@
 from django.core.exceptions import ValidationError
 from django_filters import rest_framework as filters
 from drf_spectacular.utils import extend_schema, OpenApiParameter
-from rest_framework import viewsets
+from rest_framework import viewsets, status
+from rest_framework.response import Response
 
-from greg.api.filters import PersonFilter, PersonRoleFilter
+from greg.api.filters import PersonFilter, PersonRoleFilter, PersonIdentityFilter
 from greg.api.pagination import PrimaryKeyCursorPagination
-from greg.api.serializers.person import PersonSerializer, PersonRoleSerializer
-from greg.models import Person, PersonRole
+from greg.api.serializers.person import (
+    PersonSerializer,
+    PersonRoleSerializer,
+    PersonIdentitySerializer,
+)
+from greg.models import Person, PersonRole, PersonIdentity
 
 
 class PersonViewSet(viewsets.ModelViewSet):
@@ -31,7 +36,7 @@ class PersonViewSet(viewsets.ModelViewSet):
         ]
     )
     def list(self, request, *args, **kwargs):
-        return super().list(request)
+        return super().list(request, *args, **kwargs)
 
 
 class PersonRoleViewSet(viewsets.ModelViewSet):
@@ -42,6 +47,7 @@ class PersonRoleViewSet(viewsets.ModelViewSet):
     pagination_class = PrimaryKeyCursorPagination
     filter_backends = (filters.DjangoFilterBackend,)
     filterset_class = PersonRoleFilter
+    lookup_field = "id"
 
     def get_queryset(self):
         qs = self.queryset
@@ -62,3 +68,49 @@ class PersonRoleViewSet(viewsets.ModelViewSet):
             raise ValidationError("No person id")
 
         serializer.save(person_id=person_id)
+
+
+class PersonIdentityViewSet(viewsets.ModelViewSet):
+    """
+    Person identity API
+    """
+
+    queryset = PersonIdentity.objects.all().order_by("id")
+    serializer_class = PersonIdentitySerializer
+    pagination_class = PrimaryKeyCursorPagination
+    filter_backends = (filters.DjangoFilterBackend,)
+    filterset_class = PersonIdentityFilter
+    # This is set so that the id parameter in the path of the URL is used for looking up objects
+    lookup_url_kwarg = "id"
+
+    def get_queryset(self):
+        qs = self.queryset
+        if not self.kwargs:
+            return qs.none()
+        person_id = self.kwargs["person_id"]
+        qs = qs.filter(person_id=person_id)
+        return qs
+
+    def create(self, request, *args, **kwargs):
+        # Want to get the person id which is part of the API path and then
+        # include this with the data used to create the identity for the person
+        person_id = self.kwargs["person_id"]
+
+        if person_id is None:
+            # Should not happen, the person ID is part of the API path
+            raise ValidationError("No person id")
+
+        input_data = request.data.copy()
+        input_data["person"] = person_id
+
+        serializer = self.get_serializer(data=input_data)
+
+        if serializer.is_duplicate(input_data["type"], input_data["value"]):
+            raise ValidationError("Duplicate identity entry exists")
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_create(serializer)
+        headers = self.get_success_headers(serializer.data)
+        return Response(
+            serializer.data, status=status.HTTP_201_CREATED, headers=headers
+        )
diff --git a/greg/api/views/sponsor.py b/greg/api/views/sponsor.py
index 0251a2e86274d368d61508c2c937b1205e8ea6e3..d88772b523da93de2e60eb1a6cf15c644a298111 100644
--- a/greg/api/views/sponsor.py
+++ b/greg/api/views/sponsor.py
@@ -1,14 +1,43 @@
-from rest_framework.viewsets import ReadOnlyModelViewSet
+from drf_spectacular.utils import extend_schema, OpenApiParameter
+from rest_framework import mixins
+from rest_framework.viewsets import GenericViewSet, ModelViewSet
 
 from greg.api.pagination import PrimaryKeyCursorPagination
+from greg.api.serializers import PersonSerializer
 from greg.api.serializers.sponsor import SponsorSerializer
-from greg.models import Sponsor
+from greg.models import Sponsor, Person
 
 
-class SponsorViewSet(ReadOnlyModelViewSet):
+class SponsorViewSet(ModelViewSet):
     """Sponsor API"""
 
     queryset = Sponsor.objects.all().order_by("id")
     serializer_class = SponsorSerializer
     pagination_class = PrimaryKeyCursorPagination
     lookup_field = "id"
+
+
+@extend_schema(
+    parameters=[
+        OpenApiParameter(
+            name="sponsor_id",
+            description="Sponsor ID",
+            location=OpenApiParameter.PATH,
+            required=True,
+            type=int,
+        )
+    ]
+)
+class SponsorGuestsViewSet(mixins.ListModelMixin, GenericViewSet):
+    queryset = Person.objects.all().order_by("id")
+    serializer_class = PersonSerializer
+    pagination_class = PrimaryKeyCursorPagination
+    lookup_field = "id"
+
+    def get_queryset(self):
+        qs = self.queryset
+        if not self.kwargs:
+            return qs.none()
+        sponsor_id = self.kwargs["sponsor_id"]
+        qs = qs.filter(person_roles__registered_by=sponsor_id).order_by("id")
+        return qs
diff --git a/greg/management/commands/start_schedule_tasks.py b/greg/management/commands/start_schedule_tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..2280d56698c6886d88114a91a6c3790fe8fd8614
--- /dev/null
+++ b/greg/management/commands/start_schedule_tasks.py
@@ -0,0 +1,82 @@
+import logging
+import signal
+import sys
+from threading import Event
+
+import daemon
+import lockfile
+from daemon import pidfile
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+from greg.schedule import ExpiringRolesNotification
+
+logging.config.dictConfig(settings.LOGGING)
+logger = logging.getLogger()
+
+
+def exception_handler(ex_cls, ex, tb):
+    logger.critical("Uncaught exception:", exc_info=(ex_cls, ex, tb))
+
+
+sys.excepthook = exception_handler
+
+
+class Command(BaseCommand):
+    """
+    This command starts a basic task runner. All tasks it is supposed to
+    run are given explicitly in code
+    """
+
+    help = "Start schedule task runner"
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "--detach", action="store_true", help="Run detached as a dæmon"
+        )
+        parser.add_argument(
+            "--use-pidfile", action="store_true", help="Use a PID lockfile"
+        )
+
+    def handle(self, *args, **options):
+        lock_context = None
+        if options.get("use_pidfile"):
+            lock_context = pidfile.TimeoutPIDLockFile(
+                settings.SCHEDULE_TASKS["daemon"]["pid_file"]
+            )
+
+        try:
+            exit_event = Event()
+
+            def exit_wrapper(*args):
+                exit_event.set()
+
+            with daemon.DaemonContext(
+                pidfile=lock_context,
+                files_preserve=[x.stream.fileno() for x in logger.handlers],
+                stderr=sys.stderr,
+                stdout=sys.stdout,
+                signal_map={signal.SIGINT: exit_wrapper, signal.SIGTERM: exit_wrapper},
+                detach_process=options.get("detach"),
+            ):
+                logger.info(
+                    "Running %s", "detached" if options.get("detach") else "attached"
+                )
+
+                # For now there is just one task, but the idea is that more can
+                # be added
+                expiring_role_notification_task = ExpiringRolesNotification()
+
+                while not exit_event.is_set():
+                    expiring_role_notification_task.run()
+                    # The single task is set up for far only needs to run once
+                    # a day, but running every 6 hours just in case a run fails,
+                    # it is up the task it self to figure out if needs to do
+                    # something every time it is called
+                    exit_event.wait(60 * 60 * 6)
+
+        except lockfile.AlreadyLocked as e:
+            logger.warning("Can't start daemon: %s", e)
+            sys.exit(1)
+        finally:
+            logger.info("Stopped")
diff --git a/greg/management/commands/stop_schedule_tasks.py b/greg/management/commands/stop_schedule_tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..ba8af1cd1a9a35c6f0fa20f7de0d937e57395d19
--- /dev/null
+++ b/greg/management/commands/stop_schedule_tasks.py
@@ -0,0 +1,18 @@
+import os
+import signal
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+    help = "Stop schedule tasks"
+
+    def handle(self, *args, **options):
+        try:
+            with open(settings.SCHEDULE_TASKS["daemon"]["pid_file"], "r") as f:
+                pid = int(f.read().strip())
+
+            os.kill(pid, signal.SIGINT)
+        except FileNotFoundError:
+            pass
diff --git a/greg/migrations/0001_initial.py b/greg/migrations/0001_initial.py
index 45ff7241e90d4f660c2c1dccde38bcbddf44fd76..57ab464e0e28796d720fd454be2879facd5ea84a 100644
--- a/greg/migrations/0001_initial.py
+++ b/greg/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 3.2.5 on 2021-07-15 13:31
+# Generated by Django 3.2.5 on 2021-08-23 08:53
 
 import datetime
 import dirtyfields.dirtyfields
@@ -73,10 +73,10 @@ class Migration(migrations.Migration):
                 ('updated', models.DateTimeField(auto_now=True)),
                 ('first_name', models.CharField(max_length=256)),
                 ('last_name', models.CharField(max_length=256)),
-                ('date_of_birth', models.DateField()),
-                ('email', models.EmailField(max_length=254)),
+                ('date_of_birth', models.DateField(null=True)),
+                ('email', models.EmailField(blank=True, max_length=254)),
                 ('email_verified_date', models.DateField(null=True)),
-                ('mobile_phone', models.CharField(max_length=15)),
+                ('mobile_phone', models.CharField(blank=True, max_length=15)),
                 ('mobile_phone_verified_date', models.DateField(null=True)),
                 ('registration_completed_date', models.DateField(null=True)),
                 ('token', models.CharField(blank=True, max_length=32)),
@@ -104,6 +104,14 @@ class Migration(migrations.Migration):
             },
             bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model),
         ),
+        migrations.CreateModel(
+            name='ScheduleTask',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=32)),
+                ('last_completed', models.DateTimeField(null=True)),
+            ],
+        ),
         migrations.CreateModel(
             name='Sponsor',
             fields=[
@@ -111,6 +119,8 @@ class Migration(migrations.Migration):
                 ('created', models.DateTimeField(auto_now_add=True)),
                 ('updated', models.DateTimeField(auto_now=True)),
                 ('feide_id', models.CharField(max_length=256)),
+                ('first_name', models.CharField(max_length=256)),
+                ('last_name', models.CharField(max_length=256)),
             ],
             bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model),
         ),
@@ -137,19 +147,16 @@ class Migration(migrations.Migration):
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('created', models.DateTimeField(auto_now_add=True)),
                 ('updated', models.DateTimeField(auto_now=True)),
-                ('start_date', models.DateField()),
+                ('start_date', models.DateField(null=True)),
                 ('end_date', models.DateField()),
-                ('contact_person_unit', models.TextField()),
+                ('contact_person_unit', models.TextField(blank=True)),
                 ('comments', models.TextField(blank=True)),
                 ('available_in_search', models.BooleanField(default=False)),
-                ('person', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='person_roles', to='greg.person')),
+                ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='person_roles', to='greg.person')),
                 ('registered_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sponsor_role', to='greg.sponsor')),
                 ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='person_roles', to='greg.role')),
                 ('unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='unit_person_role', to='greg.organizationalunit')),
             ],
-            options={
-                'abstract': False,
-            },
             bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model),
         ),
         migrations.CreateModel(
@@ -158,12 +165,12 @@ class Migration(migrations.Migration):
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('created', models.DateTimeField(auto_now_add=True)),
                 ('updated', models.DateTimeField(auto_now=True)),
-                ('type', models.CharField(choices=[('PASSPORT_NUMBER', 'Passport Number'), ('FEIDE_ID', 'Feide Id')], max_length=15)),
+                ('type', models.CharField(choices=[('ID_PORTEN', 'Id Porten'), ('FEIDE_ID', 'Feide Id'), ('PASSPORT', 'Passport'), ('DRIVERS_LICENSE', 'Drivers License'), ('NATIONAL_ID_CARD', 'National Id Card'), ('NATIONAL_ID_NUMBER', 'National Id Number'), ('OTHER', 'Other')], max_length=18)),
                 ('source', models.CharField(max_length=256)),
                 ('value', models.CharField(max_length=256)),
                 ('verified', models.CharField(blank=True, choices=[('AUTOMATIC', 'Automatic'), ('MANUAL', 'Manual')], max_length=9)),
                 ('verified_when', models.DateField(null=True)),
-                ('person', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='person', to='greg.person')),
+                ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to='greg.person')),
                 ('verified_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sponsor', to='greg.sponsor')),
             ],
             options={
@@ -177,9 +184,9 @@ class Migration(migrations.Migration):
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('created', models.DateTimeField(auto_now_add=True)),
                 ('updated', models.DateTimeField(auto_now=True)),
-                ('consent_given_at', models.DateField()),
+                ('consent_given_at', models.DateField(null=True)),
                 ('consent', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='link_person_consent', to='greg.consent')),
-                ('person', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='link_person_consent', to='greg.person')),
+                ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_person_consent', to='greg.person')),
             ],
             bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model),
         ),
@@ -201,6 +208,10 @@ class Migration(migrations.Migration):
             model_name='sponsor',
             constraint=models.UniqueConstraint(fields=('feide_id',), name='unique_feide_id'),
         ),
+        migrations.AddConstraint(
+            model_name='personrole',
+            constraint=models.UniqueConstraint(fields=('person_id', 'role_id', 'unit_id', 'start_date', 'end_date'), name='person_role_unique'),
+        ),
         migrations.AddConstraint(
             model_name='personconsent',
             constraint=models.UniqueConstraint(fields=('person', 'consent'), name='person_consent_unique'),
diff --git a/greg/models.py b/greg/models.py
index fc471b9133b4e04fb5d530d02d54441e70711bb3..c614dc3ef52eecead28e1f624582462a258f3d84 100644
--- a/greg/models.py
+++ b/greg/models.py
@@ -1,4 +1,7 @@
-from datetime import date
+from datetime import (
+    date,
+    datetime,
+)
 
 from dirtyfields import DirtyFieldsMixin
 from django.db import models
@@ -34,10 +37,10 @@ class Person(BaseModel):
 
     first_name = models.CharField(max_length=256)
     last_name = models.CharField(max_length=256)
-    date_of_birth = models.DateField()
-    email = models.EmailField()
+    date_of_birth = models.DateField(null=True)
+    email = models.EmailField(blank=True)
     email_verified_date = models.DateField(null=True)
-    mobile_phone = models.CharField(max_length=15)
+    mobile_phone = models.CharField(max_length=15, blank=True)
     mobile_phone_verified_date = models.DateField(null=True)
     registration_completed_date = models.DateField(null=True)
     token = models.CharField(max_length=32, blank=True)
@@ -57,6 +60,65 @@ class Person(BaseModel):
             self.last_name,
         )
 
+    @property
+    def is_registered(self) -> bool:
+        """
+        A registered guest is a person who has completed
+        the registration process.
+
+        The registration process requires that the guest has verified
+        their email address via a token link, filled in the required
+        personal information along with providing at least one
+        verification method, and accepted the institution's mandatory
+        consent forms.
+
+        The registered guest may or may not already be verified,
+        depending on the verification method.  However, before a
+        guest is cleared for account creation at the institution's
+        IGA, the guest must be both registered (``is_registered``)
+        and verified (``is_verified``).
+        """
+        # registration_completed_date is set only after accepting consents
+        return (
+            self.registration_completed_date is not None
+            and self.registration_completed_date <= datetime.now()
+        )
+
+    @property
+    def is_verified(self) -> bool:
+        """
+        A verified guest is a person whom has had their personal
+        identity verified.
+
+        Due to the diversity of guests at a university institution,
+        there are many ways for guests to identify themselves.
+        These include Feide ID, passport number, driver's license,
+        national ID card, or another manual (human) verification.
+
+        Some of these methods are implicitly trusted (Feide ID) because
+        the guest is likely a visitor from another academic institution
+        who has already been pre-verified.  Others are manul, such
+        as the sponsor vouching for having checked the guest's
+        personal details against his or her passport.
+
+        The verified guest may or may not have completed
+        the registration process which implies that it is only
+        the combination of being registered (``is_registered``)
+        and being verified (``is_verified``) that qualifies for being cleared
+        for account creation in the IGA.
+
+        Note that we do not distinguish between the quality,
+        authenticity, or trust level of the guest's associated identities.
+        """
+        # the requirement is minimum one personal identity
+        return (
+            self.identities.filter(
+                verified_when__isnull=False,
+                verified_when__lte=datetime.now(),
+            ).count()
+            >= 1
+        )
+
 
 class Role(BaseModel):
     """A role variant."""
@@ -85,7 +147,7 @@ class PersonRole(BaseModel):
     """The relationship between a person and a role."""
 
     person = models.ForeignKey(
-        "Person", on_delete=models.PROTECT, related_name="person_roles"
+        "Person", on_delete=models.CASCADE, related_name="person_roles"
     )
     role = models.ForeignKey(
         "Role", on_delete=models.PROTECT, related_name="person_roles"
@@ -93,16 +155,25 @@ class PersonRole(BaseModel):
     unit = models.ForeignKey(
         "OrganizationalUnit", on_delete=models.PROTECT, related_name="unit_person_role"
     )
-    start_date = models.DateField()
+    # The start date can be null for people that are already
+    # attached to the institution but does not have a guest account
+    start_date = models.DateField(null=True)
     end_date = models.DateField()
-    # TODO Is this field needed?
-    contact_person_unit = models.TextField()
+    contact_person_unit = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     available_in_search = models.BooleanField(default=False)
     registered_by = models.ForeignKey(
         "Sponsor", on_delete=models.PROTECT, related_name="sponsor_role"
     )
 
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(
+                fields=["person_id", "role_id", "unit_id", "start_date", "end_date"],
+                name="person_role_unique",
+            )
+        ]
+
     def __repr__(self):
         return "{}(id={!r}, person={!r}, role={!r})".format(
             self.__class__.__name__, self.pk, self.person, self.role
@@ -131,19 +202,25 @@ class Notification(BaseModel):
 
 
 class PersonIdentity(BaseModel):
-    # TODO: Add more types
     class IdentityType(models.TextChoices):
-        PASSPORT_NUMBER = "PASSPORT_NUMBER"
+        ID_PORTEN = "ID_PORTEN"
         FEIDE_ID = "FEIDE_ID"
+        PASSPORT = "PASSPORT"
+        DRIVERS_LICENSE = "DRIVERS_LICENSE"
+        NATIONAL_ID_CARD = "NATIONAL_ID_CARD"
+        # In Norway this is the foedselsnummer
+        NATIONAL_ID_NUMBER = "NATIONAL_ID_NUMBER"
+        # Sponsor writes what is used in the value column
+        OTHER = "OTHER"
 
     class Verified(models.TextChoices):
         AUTOMATIC = "AUTOMATIC"
         MANUAL = "MANUAL"
 
     person = models.ForeignKey(
-        "Person", on_delete=models.PROTECT, related_name="person"
+        "Person", on_delete=models.CASCADE, related_name="identities"
     )
-    type = models.CharField(max_length=15, choices=IdentityType.choices)
+    type = models.CharField(max_length=18, choices=IdentityType.choices)
     source = models.CharField(max_length=256)
     value = models.CharField(max_length=256)
     verified = models.CharField(max_length=9, choices=Verified.choices, blank=True)
@@ -153,15 +230,14 @@ class PersonIdentity(BaseModel):
     verified_when = models.DateField(null=True)
 
     def __repr__(self):
-        return (
-            "{}(id={!r}, type={!r}, source={!r}, value={!r}, verified_by={!r})".format(
-                self.__class__.__name__,
-                self.pk,
-                self.type,
-                self.source,
-                self.value,
-                self.verified_by,
-            )
+        return "{}(id={!r}, type={!r}, source={!r}, value={!r}, verified_by={!r}, verified_when={!r})".format(
+            self.__class__.__name__,
+            self.pk,
+            self.type,
+            self.source,
+            self.value,
+            self.verified_by,
+            self.verified_when,
         )
 
 
@@ -197,12 +273,13 @@ class PersonConsent(BaseModel):
     """
 
     person = models.ForeignKey(
-        "Person", on_delete=models.PROTECT, related_name="link_person_consent"
+        "Person", on_delete=models.CASCADE, related_name="link_person_consent"
     )
     consent = models.ForeignKey(
         "Consent", on_delete=models.PROTECT, related_name="link_person_consent"
     )
-    consent_given_at = models.DateField()
+    # If the date is blank it means the person has not given consent yet
+    consent_given_at = models.DateField(null=True)
 
     class Meta:
         constraints = [
@@ -248,6 +325,9 @@ class Sponsor(BaseModel):
     """
 
     feide_id = models.CharField(max_length=256)
+    first_name = models.CharField(max_length=256)
+    last_name = models.CharField(max_length=256)
+
     units = models.ManyToManyField(
         "OrganizationalUnit",
         through="SponsorOrganizationalUnit",
@@ -255,8 +335,12 @@ class Sponsor(BaseModel):
     )
 
     def __repr__(self):
-        return "{}(id={!r}, feide_id={!r})".format(
-            self.__class__.__name__, self.pk, self.feide_id
+        return "{}(id={!r}, feide_id={!r}, first_name={!r}, last_name={!r})".format(
+            self.__class__.__name__,
+            self.pk,
+            self.feide_id,
+            self.first_name,
+            self.last_name,
         )
 
     class Meta:
@@ -294,3 +378,17 @@ class SponsorOrganizationalUnit(BaseModel):
             self.organizational_unit,
             self.hierarchical_access,
         )
+
+
+class ScheduleTask(models.Model):
+    """
+    Stores information about a task
+    """
+
+    name = models.CharField(max_length=32)
+    last_completed = models.DateTimeField(null=True)
+
+    def __repr__(self):
+        return "{}(id={!r}, name={!r}, last_completed={!r})".format(
+            self.__class__.__name__, self.pk, self.name, self.last_completed
+        )
diff --git a/greg/schedule.py b/greg/schedule.py
new file mode 100644
index 0000000000000000000000000000000000000000..e9a907e36be35fd8c55d6ff7fc215c468c01731d
--- /dev/null
+++ b/greg/schedule.py
@@ -0,0 +1,89 @@
+import time
+from datetime import date, datetime, timedelta
+from typing import Optional
+
+from abc import ABC, abstractmethod
+from django.utils import timezone
+
+from greg.models import PersonRole, Notification, ScheduleTask
+
+
+class BaseSchedule(ABC):
+    """
+    Provides common methods for tasks
+    """
+
+    task_object: ScheduleTask
+
+    def __init__(self, name: str):
+        self.task_object = ScheduleTask.objects.get(name=name)
+
+    def run(self):
+        self._run_internal()
+        self.task_object.last_completed = timezone.now()
+        self.task_object.save()
+
+    @abstractmethod
+    def _run_internal(self):
+        pass
+
+    def get_last_run(self) -> Optional[datetime]:
+        self.task_object.refresh_from_db()
+        return self.task_object.last_completed
+
+    def _store_notification(
+        self, identifier, object_type, operation, **kwargs
+    ) -> Notification:
+        return Notification.objects.create(
+            identifier=identifier,
+            object_type=object_type,
+            operation=operation,
+            issued_at=int(time.time()),
+            meta=kwargs,
+        )
+
+
+class ExpiringRolesNotification(BaseSchedule):
+    """
+    This task does a simple check for roles that will expire in 30 days
+    and creates entries in the notification table for them.
+
+    There should be an entry in the ScheduleTask-table with name role_expiration
+    before this task is run.
+
+    Some assumptions that are made:
+
+    - The task will be run every day, it does
+    not keep track of which roles it has or has not notified, it only
+    looks at which roles will expire in exactly 30 days
+
+    - If there are roles created that expire in less than 30 days, no
+    notification is necessary
+    """
+
+    EXPIRATION_THRESHOLD_DAYS = 30
+
+    def __init__(self):
+        # Will raise exception if there is not exactly one result
+        super().__init__("role_expiration")
+
+    def _run_internal(self):
+        last_run = self.get_last_run()
+        # Only run once per day
+        if last_run is None or last_run.date() != date.today():
+            check_date = datetime.today() + timedelta(
+                days=self.EXPIRATION_THRESHOLD_DAYS
+            )
+            self.__get_roles_about_to_expire(check_date)
+
+    def __get_roles_about_to_expire(self, end_date: date):
+        roles_about_to_expire = PersonRole.objects.filter(end_date=end_date)
+
+        for person_role in roles_about_to_expire:
+            meta = {"person_id": person_role.person.id, "role_id": person_role.role.id}
+            self._store_notification(
+                identifier=person_role.id,
+                object_type="PersonRole",
+                operation="expire_reminder",
+                **meta
+            )
diff --git a/greg/signals.py b/greg/signals.py
index 3a3a52726fde5a9651633d7c0e658f3e79c0dfe7..e1d0f60c7abab9bd97d0c988a5e6e46ea6d4037d 100644
--- a/greg/signals.py
+++ b/greg/signals.py
@@ -1,5 +1,6 @@
 import time
 import logging
+from typing import Dict
 
 from django.db import models
 from django.dispatch import receiver
@@ -9,6 +10,9 @@ from greg.models import (
     PersonRole,
     Role,
     Notification,
+    PersonIdentity,
+    PersonConsent,
+    Consent,
 )
 
 logger = logging.getLogger(__name__)
@@ -17,6 +21,8 @@ SUPPORTED_MODELS = (
     Person,
     PersonRole,
     Role,
+    PersonIdentity,
+    PersonConsent,
 )
 
 
@@ -30,6 +36,10 @@ def disconnect_notification_signals(*args, **kwargs):
     models.signals.pre_save.disconnect(dispatch_uid="add_changed_fields_callback")
     models.signals.post_save.disconnect(dispatch_uid="save_notification_callback")
     models.signals.post_delete.disconnect(dispatch_uid="delete_notification_callback")
+    models.signals.m2m_changed.connect(
+        receiver=m2m_changed_notification_callback,
+        dispatch_uid="m2m_changed_notification_callback",
+    )
 
 
 @receiver(models.signals.post_migrate)
@@ -70,7 +80,7 @@ def add_changed_fields_callback(sender, instance, raw, *args, **kwargs):
     Makes note of any dirty (changed) fields before they are saved,
     stuffing them in the instance for use in any post-save callbacks.
     """
-    if not isinstance(instance, (Person, PersonRole)):
+    if not isinstance(instance, SUPPORTED_MODELS):
         return
     changed = instance.is_dirty()
     if not changed:
@@ -84,11 +94,8 @@ def add_changed_fields_callback(sender, instance, raw, *args, **kwargs):
 def save_notification_callback(sender, instance, created, *args, **kwargs):
     if not isinstance(instance, SUPPORTED_MODELS):
         return
-    meta = {}
+    meta = _create_metadata(instance)
     operation = "add" if created else "update"
-    if isinstance(instance, PersonRole):
-        meta["person_id"] = instance.person.id
-        meta["role_id"] = instance.role.id
     _store_notification(
         identifier=instance.id,
         object_type=instance._meta.object_name,
@@ -101,13 +108,78 @@ def save_notification_callback(sender, instance, created, *args, **kwargs):
 def delete_notification_callback(sender, instance, *args, **kwargs):
     if not isinstance(instance, SUPPORTED_MODELS):
         return
-    meta = {}
-    if isinstance(instance, PersonRole):
-        meta["person_id"] = instance.person.id
-        meta["role_id"] = instance.role.id
+    meta = _create_metadata(instance)
     _store_notification(
         identifier=instance.id,
         object_type=instance._meta.object_name,
         operation="delete",
         **meta
     )
+
+
+@receiver(models.signals.m2m_changed, dispatch_uid="m2m_changed_notification_callback")
+def m2m_changed_notification_callback(
+    sender, instance, action, *args, model=None, pk_set=None, **kwargs
+):
+    if action not in ("post_add", "post_remove"):
+        return
+    if sender not in (PersonConsent, PersonRole, PersonIdentity):
+        return
+
+    operation = "add" if action == "post_add" else "delete"
+    instance_type = type(instance)
+
+    if sender is PersonConsent:
+        person_consents = []
+        if instance_type is Person and model is Consent:
+            person_consents = PersonConsent.objects.filter(
+                person_id=instance.id, consent_id__in=pk_set
+            )
+        elif instance_type is Consent and model is Person:
+            person_consents = PersonConsent.objects.filter(
+                consent_id=instance.id, person_id__in=pk_set
+            )
+
+        for pc in person_consents:
+            meta = _create_metadata(pc)
+            _store_notification(
+                identifier=pc.id,
+                object_type=PersonConsent._meta.object_name,
+                operation=operation,
+                **meta
+            )
+    elif sender is PersonRole:
+        person_roles = []
+        if instance_type is Person and model is Role:
+            person_roles = PersonRole.objects.filter(
+                person_id=instance.id, role_id__in=pk_set
+            )
+        elif instance_type is Role and model is Person:
+            person_roles = PersonRole.objects.filter(
+                role_id=instance.id, person_id__in=pk_set
+            )
+
+        for pr in person_roles:
+            meta = _create_metadata(pr)
+            _store_notification(
+                identifier=pr.id,
+                object_type=PersonRole._meta.object_name,
+                operation=operation,
+                **meta
+            )
+
+
+def _create_metadata(instance) -> Dict:
+    meta = {}
+
+    if isinstance(instance, PersonRole):
+        meta["person_id"] = instance.person.id
+        meta["role_id"] = instance.role.id
+    if isinstance(instance, PersonIdentity):
+        meta["person_id"] = instance.person.id
+        meta["identity_id"] = instance.id
+    if isinstance(instance, PersonConsent):
+        meta["person_id"] = instance.person.id
+        meta["consent_id"] = instance.consent.id
+
+    return meta
diff --git a/greg/tests/api/test_consent.py b/greg/tests/api/test_consent.py
index 24ae8c837c73054d8edcad98abcefa1f39624e59..dd59ad52e5a87aa7eb7b16d13aeae765eb72e532 100644
--- a/greg/tests/api/test_consent.py
+++ b/greg/tests/api/test_consent.py
@@ -22,7 +22,7 @@ def consent_foo() -> Consent:
 
 @pytest.mark.django_db
 def test_get_consent(client, consent_foo):
-    resp = client.get(reverse("consent-detail", kwargs={"id": consent_foo.id}))
+    resp = client.get(reverse("v1:consent-detail", kwargs={"id": consent_foo.id}))
     assert resp.status_code == status.HTTP_200_OK
     data = resp.json()
     assert data.get("id") == consent_foo.id
diff --git a/greg/tests/api/test_person.py b/greg/tests/api/test_person.py
index f79d9d26a2475b542cc22dda4c9dbb7ed7104ce4..772c6ee546a8823dc339d19b12495ffe1008a0c6 100644
--- a/greg/tests/api/test_person.py
+++ b/greg/tests/api/test_person.py
@@ -1,37 +1,78 @@
-import pytest
+from typing import Dict
 
+import pytest
 from rest_framework import status
 from rest_framework.reverse import reverse
 from rest_framework.status import HTTP_200_OK
 
-from greg.models import Person
+from django.core.exceptions import ValidationError
+
+from greg.models import (
+    PersonIdentity,
+    Sponsor,
+    Role,
+    OrganizationalUnit,
+    Consent,
+    Person,
+    PersonRole,
+)
 
 
 @pytest.fixture
-def person_foo() -> Person:
-    return Person.objects.create(
-        first_name="Foo",
-        last_name="Foo",
-        date_of_birth="2001-01-27",
-        email="test@example.org",
-        mobile_phone="123456788",
+def role_visiting_professor() -> Role:
+    return Role.objects.create(
+        type="visiting_professor",
+        name_nb="Gjesteprofessor",
+        name_en="Visiting professor",
+        description_nb="Gjesteprofessor",
+        description_en="Visiting professor",
+        default_duration_days=180,
     )
 
 
 @pytest.fixture
-def person_bar() -> Person:
-    return Person.objects.create(
-        first_name="Bar",
-        last_name="Bar",
-        date_of_birth="2000-07-01",
-        email="test2@example.org",
-        mobile_phone="123456789",
+def unit_human_resources() -> OrganizationalUnit:
+    return OrganizationalUnit.objects.create(
+        orgreg_id="org_unit_1", name_nb="Personal", name_en="Human Resources"
+    )
+
+
+@pytest.fixture()
+def sponsor_bar() -> Sponsor:
+    return Sponsor.objects.create(feide_id="bar")
+
+
+@pytest.fixture
+def role_data_guest(
+    role_test_guest: Role, sponsor_bar: Sponsor, unit_foo: OrganizationalUnit
+) -> Dict:
+    return {
+        "role": "Test Guest",
+        "start_date": "2021-06-10",
+        "end_date": "2021-08-10",
+        "registered_by": sponsor_bar.id,
+        "unit": unit_foo.id,
+    }
+
+
+@pytest.fixture
+def consent_foo() -> Consent:
+    return Consent.objects.create(
+        type="test_consent",
+        consent_name_en="Test1",
+        consent_name_nb="Test2",
+        consent_description_en="Test description",
+        consent_description_nb="Test beskrivelse",
+        consent_link_en="https://example.org",
+        consent_link_nb="https://example.org",
+        valid_from="2018-01-20",
+        user_allowed_to_change=True,
     )
 
 
 @pytest.mark.django_db
 def test_get_person(client, person_foo):
-    resp = client.get(reverse("person-detail", kwargs={"id": person_foo.id}))
+    resp = client.get(reverse("v1:person-detail", kwargs={"id": person_foo.id}))
     assert resp.status_code == HTTP_200_OK
     data = resp.json()
     assert data.get("id") == person_foo.id
@@ -41,44 +82,48 @@ def test_get_person(client, person_foo):
 
 @pytest.mark.django_db
 def test_persons(client, person_foo, person_bar):
-    resp = client.get(reverse("person-list"))
+    resp = client.get(reverse("v1:person-list"))
     assert resp.status_code == HTTP_200_OK
     data = resp.json()
     assert len(data["results"]) == 2
 
 
 @pytest.mark.django_db
-def test_persons_verified_filter_include(client, setup_db_test_data):
-    url = reverse("person-list")
+def test_persons_verified_filter_include(
+    client, person_bar, person_foo, person_foo_verified
+):
+    url = reverse("v1:person-list")
     response = client.get(url, {"verified": "true"})
     results = response.json()["results"]
     assert len(results) == 1
-    # The following person will have a verified identity set up for him
-    # in the test data
-    assert results[0]["first_name"] == "Christopher"
-    assert results[0]["last_name"] == "Flores"
+    assert results[0]["first_name"] == "Foo"
+    assert results[0]["last_name"] == "Foo"
 
 
 @pytest.mark.django_db
-def test_persons_verified_filter_exclude(client, setup_db_test_data):
-    url = reverse("person-list")
+def test_persons_verified_filter_exclude(
+    client, person_bar, person_foo, person_foo_verified
+):
+    url = reverse("v1:person-list")
     response = client.get(url, {"verified": "false"})
     results = response.json()["results"]
-    names = [(result["first_name"], result["last_name"]) for result in results]
-    assert len(results) == 9
-    assert ("Christopher", "Flores") not in names
+    assert len(results) == 1
+    assert results[0]["first_name"] == "Bar"
+    assert results[0]["last_name"] == "Bar"
 
 
 @pytest.mark.django_db
-def test_add_role(client, person_foo):
-    url = reverse("person_role-list", kwargs={"person_id": person_foo.id})
+def test_add_role(
+    client, person_foo, role_visiting_professor, sponsor_guy, unit_human_resources
+):
+    url = reverse("v1:person_role-list", kwargs={"person_id": person_foo.id})
     roles_for_person = client.get(url).json()["results"]
 
     # Check that there are no roles for the person, and then add one
     assert len(roles_for_person) == 0
 
     role_data = {
-        "role": "Visiting Professor",
+        "role": "visiting_professor",
         "start_date": "2021-06-10",
         "end_date": "2021-08-10",
         "registered_by": "1",
@@ -94,3 +139,284 @@ def test_add_role(client, person_foo):
     # Check that the role shows up when listing roles for the person now
     assert len(roles_for_person) == 1
     assert roles_for_person[0]["id"] == response_data["id"]
+
+
+@pytest.mark.django_db
+def test_update_role(client, person_foo, role_data_guest):
+    url = reverse("v1:person_role-list", kwargs={"person_id": person_foo.id})
+    response = client.post(url, role_data_guest)
+    response_data = response.json()
+
+    assert response_data["start_date"] == "2021-06-10"
+
+    # Update the date and check that the change is registered
+    role_id = response.json()["id"]
+    updated_role = role_data_guest.copy()
+    updated_role["start_date"] = "2021-06-15"
+
+    url_detail = reverse(
+        "v1:person_role-detail", kwargs={"person_id": person_foo.id, "id": role_id}
+    )
+    client.patch(url_detail, updated_role)
+
+    updated_role_data = client.get(url)
+    updated_data = updated_role_data.json()["results"][0]
+
+    assert updated_data["id"] == role_id
+    assert updated_data["start_date"] == "2021-06-15"
+
+
+@pytest.mark.django_db
+def test_delete_role(client, person_foo, role_data_guest):
+    url = reverse("v1:person_role-list", kwargs={"person_id": person_foo.id})
+    role_id = client.post(url, role_data_guest).json()["id"]
+    roles_for_person = client.get(url).json()["results"]
+
+    assert len(roles_for_person) == 1
+
+    url_detail = reverse(
+        "v1:person_role-detail", kwargs={"person_id": person_foo.id, "id": role_id}
+    )
+    client.delete(url_detail)
+
+    assert len(client.get(url).json()["results"]) == 0
+
+
+@pytest.mark.django_db
+def test_identity_list(
+    client, person_foo, person_foo_verified, person_foo_not_verified
+):
+    response = client.get(
+        reverse("v1:person-list"),
+        data={"first_name": person_foo.first_name, "last_name": person_foo.last_name},
+    )
+    person_id = response.json()["results"][0]["id"]
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_id})
+    )
+    data = response.json()["results"]
+    assert len(data) == 2
+
+
+@pytest.mark.django_db
+def test_identity_add(client, person_foo):
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    results = response.json()["results"]
+    assert len(results) == 0
+
+    data = {
+        "type": PersonIdentity.IdentityType.FEIDE_ID,
+        "source": "Test source",
+        "value": "12345",
+    }
+    client.post(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id}),
+        data=data,
+    )
+
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    results = response.json()["results"]
+    assert len(results) == 1
+
+
+@pytest.mark.django_db
+def test_identity_add_duplicate(client, person_foo, person_bar):
+    data = {
+        "type": PersonIdentity.IdentityType.FEIDE_ID,
+        "source": "Test source",
+        "value": "12345",
+    }
+    client.post(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_bar.id}),
+        data=data,
+    )
+
+    with pytest.raises(ValidationError):
+        client.post(
+            reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id}),
+            data=data,
+        )
+
+
+@pytest.mark.django_db
+def test_identity_add_valid_duplicate(client, person_foo, person_bar):
+    data = {
+        "type": PersonIdentity.IdentityType.OTHER,
+        "source": "Test source",
+        "value": "12345",
+    }
+    client.post(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_bar.id}),
+        data=data,
+    )
+
+    client.post(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id}),
+        data=data,
+    )
+
+
+@pytest.mark.django_db
+def test_identity_delete(client, person_foo):
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    results = response.json()["results"]
+    assert len(results) == 0
+
+    data = {
+        "type": PersonIdentity.IdentityType.FEIDE_ID,
+        "source": "Test source",
+        "value": "12345",
+    }
+    post_response = client.post(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id}),
+        data=data,
+    )
+    identity_id = post_response.json()["id"]
+
+    # Create two identities for the user
+    data = {
+        "type": PersonIdentity.IdentityType.PASSPORT,
+        "source": "Test",
+        "value": "1234413241235",
+    }
+    post_response2 = client.post(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id}),
+        data=data,
+    )
+    identity_id2 = post_response2.json()["id"]
+
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    results = response.json()["results"]
+    assert len(results) == 2
+
+    # Delete the first identity created
+    client.delete(
+        reverse(
+            "v1:person_identity-detail",
+            kwargs={"person_id": person_foo.id, "id": identity_id},
+        )
+    )
+
+    # Check that the other identity is still there
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    results = response.json()["results"]
+
+    assert len(results) == 1
+    assert results[0]["id"] == identity_id2
+
+
+@pytest.mark.django_db
+def test_identity_update(client, person_foo):
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    results = response.json()["results"]
+    assert len(results) == 0
+
+    data = {
+        "type": PersonIdentity.IdentityType.FEIDE_ID,
+        "source": "Test source",
+        "value": "12345",
+    }
+    client.post(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id}),
+        data=data,
+    )
+
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    results = response.json()["results"]
+    assert len(results) == 1
+
+    identity_id = results[0]["id"]
+
+    assert results[0]["type"] == data["type"]
+    assert results[0]["source"] == data["source"]
+    assert results[0]["value"] == data["value"]
+
+    data_updated = {
+        "type": PersonIdentity.IdentityType.PASSPORT,
+        "source": "Test",
+        "value": "10000",
+    }
+    patch_response = client.patch(
+        reverse(
+            "v1:person_identity-detail",
+            kwargs={"person_id": person_foo.id, "id": identity_id},
+        ),
+        data=data_updated,
+    )
+    assert patch_response.status_code == status.HTTP_200_OK
+
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    results = response.json()["results"]
+    assert len(results) == 1
+
+    assert results[0]["type"] == data_updated["type"]
+    assert results[0]["source"] == data_updated["source"]
+    assert results[0]["value"] == data_updated["value"]
+
+
+@pytest.mark.django_db
+def test_remove_person(
+    client, person_foo, role_data_guest, person_foo_verified, person_foo_not_verified
+):
+    url = reverse("v1:person_role-list", kwargs={"person_id": person_foo.id})
+    client.post(url, role_data_guest)
+
+    roles_for_person = client.get(url).json()["results"]
+    assert len(roles_for_person) == 1
+
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    assert len(response.json()["results"]) == 2
+
+    # Delete the person and check that the data has been removed
+    client.delete(reverse("v1:person-detail", kwargs={"id": person_foo.id}))
+
+    updated_role_data = client.get(url)
+    assert len(updated_role_data.json()["results"]) == 0
+
+    response = client.get(
+        reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id})
+    )
+    assert len(response.json()["results"]) == 0
+
+
+@pytest.mark.django_db
+def test_add_duplicate_role_fails(
+    client, person_foo: Person, person_foo_role: PersonRole
+):
+    url = reverse("v1:person_role-list", kwargs={"person_id": person_foo.id})
+    roles_for_person = client.get(url).json()["results"]
+
+    assert len(roles_for_person) == 1
+
+    role_data = {
+        "role": person_foo_role.role_id,
+        "start_date": person_foo_role.start_date,
+        "end_date": person_foo_role.end_date,
+        "registered_by": person_foo_role.registered_by,
+        "unit": person_foo_role.unit_id,
+    }
+    response = client.post(url, role_data)
+    # If the role cannot be create the return code is 400
+    assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+    # Check that there is still only one role attached to the person
+    roles_for_person = client.get(url).json()["results"]
+    assert len(roles_for_person) == 1
diff --git a/greg/tests/api/test_sponsor.py b/greg/tests/api/test_sponsor.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce444dbbf5e0dbd58527895c81381a4ffcf1b635
--- /dev/null
+++ b/greg/tests/api/test_sponsor.py
@@ -0,0 +1,44 @@
+import pytest
+from rest_framework import status
+
+from rest_framework.reverse import reverse
+
+
+@pytest.mark.django_db
+def test_add_sponsor(client):
+    data = {
+        "feide_id": "sponsor@example.org",
+        "first_name": "Test",
+        "last_name": "Sponsor",
+    }
+
+    post_response = client.post(reverse("v1:sponsor-list"), data=data)
+
+    assert post_response.status_code == status.HTTP_201_CREATED
+
+    response_data = post_response.json()
+    list_response = client.get(
+        reverse("v1:sponsor-detail", kwargs={"id": response_data["id"]})
+    )
+    list_response_data = list_response.json()
+
+    assert list_response_data["feide_id"] == data["feide_id"]
+    assert list_response_data["first_name"] == data["first_name"]
+    assert list_response_data["last_name"] == data["last_name"]
+
+
+@pytest.mark.django_db
+def test_sponsor_guest_list(client, sponsor_guy, person_foo_role):
+    url = reverse("v1:sponsor_guests-list", kwargs={"sponsor_id": sponsor_guy.id})
+    guests_for_sponsor = client.get(url).json()["results"]
+
+    assert len(guests_for_sponsor) == 1
+    assert guests_for_sponsor[0]["id"] == person_foo_role.person_id
+
+
+@pytest.mark.django_db
+def test_sponsor_empty_guest_list(client, sponsor_guy):
+    url = reverse("v1:sponsor_guests-list", kwargs={"sponsor_id": sponsor_guy.id})
+    guests_for_sponsor = client.get(url).json()["results"]
+
+    assert len(guests_for_sponsor) == 0
diff --git a/greg/tests/conftest.py b/greg/tests/conftest.py
index bbdf24d3973f2d452e7e45e22ae1a81e2bcda7b5..63646efdd9947cd04f5ec31079c855447e8b73b2 100644
--- a/greg/tests/conftest.py
+++ b/greg/tests/conftest.py
@@ -4,27 +4,22 @@ from rest_framework.authtoken.models import Token
 from rest_framework.test import APIClient
 from django.contrib.auth import get_user_model
 
-
-# faker spams the logs with localisation warnings
-# see https://github.com/joke2k/faker/issues/753
 import pytest
 
-from greg.tests.populate_database import DatabasePopulation
+from greg.models import (
+    Person,
+    Sponsor,
+    PersonIdentity,
+    Role,
+    OrganizationalUnit,
+    PersonRole,
+)
 
+# faker spams the logs with localisation warnings
+# see https://github.com/joke2k/faker/issues/753
 logging.getLogger("faker").setLevel(logging.ERROR)
 
 
-# The database is populated once when scope is session.
-# If the scope is changed to function some additional
-# logic is needed to make sure the old data is cleaned
-# before the seeding is run again
-@pytest.fixture(scope="session")
-def setup_db_test_data(django_db_setup, django_db_blocker):
-    with django_db_blocker.unblock():
-        database_seeder = DatabasePopulation()
-        database_seeder.populate_database()
-
-
 @pytest.fixture
 def client() -> APIClient:
     user, _ = get_user_model().objects.get_or_create(username="test")
@@ -32,3 +27,82 @@ def client() -> APIClient:
     client = APIClient()
     client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
     return client
+
+
+@pytest.fixture
+def person_foo() -> Person:
+    return Person.objects.create(
+        first_name="Foo",
+        last_name="Foo",
+        date_of_birth="2001-01-27",
+        email="test@example.org",
+        mobile_phone="123456788",
+    )
+
+
+@pytest.fixture
+def person_bar() -> Person:
+    return Person.objects.create(
+        first_name="Bar",
+        last_name="Bar",
+        date_of_birth="2000-07-01",
+        email="test2@example.org",
+        mobile_phone="123456789",
+    )
+
+
+@pytest.fixture
+def sponsor_guy() -> Sponsor:
+    return Sponsor.objects.create(
+        feide_id="guy@example.org", first_name="Sponsor", last_name="Guy"
+    )
+
+
+@pytest.fixture
+def person_foo_verified(person_foo, sponsor_guy) -> PersonIdentity:
+    return PersonIdentity.objects.create(
+        person=person_foo,
+        type=PersonIdentity.IdentityType.PASSPORT,
+        source="Test",
+        value="12345",
+        verified=PersonIdentity.Verified.MANUAL,
+        verified_by=sponsor_guy,
+        verified_when="2021-06-15",
+    )
+
+
+@pytest.fixture
+def person_foo_not_verified(person_foo) -> PersonIdentity:
+    return PersonIdentity.objects.create(
+        person=person_foo,
+        type=PersonIdentity.IdentityType.DRIVERS_LICENSE,
+        source="Test",
+        value="12345",
+    )
+
+
+@pytest.fixture()
+def role_test_guest() -> Role:
+    return Role.objects.create(type="Test Guest")
+
+
+@pytest.fixture
+def unit_foo() -> OrganizationalUnit:
+    return OrganizationalUnit.objects.create(orgreg_id="12345", name_en="foo_unit")
+
+
+@pytest.fixture
+def person_foo_role(
+    person_foo: Person,
+    role_test_guest: Role,
+    sponsor_guy: Sponsor,
+    unit_foo: OrganizationalUnit,
+) -> PersonRole:
+    return PersonRole.objects.create(
+        person=person_foo,
+        role=role_test_guest,
+        start_date="2021-08-02",
+        end_date="2021-08-06",
+        registered_by=sponsor_guy,
+        unit=unit_foo,
+    )
diff --git a/greg/tests/models/test_consent.py b/greg/tests/models/test_consent.py
index ad800bd596686e43520556a1acb2ec22aa2a76c6..8f7711e08ff37e9e20dc7f8ce9eacca45f51ce34 100644
--- a/greg/tests/models/test_consent.py
+++ b/greg/tests/models/test_consent.py
@@ -1,8 +1,11 @@
+import datetime
+
 import pytest
 
 from greg.models import (
     Person,
     Consent,
+    PersonConsent,
 )
 
 
@@ -17,9 +20,9 @@ def person() -> Person:
     )
 
 
-@pytest.mark.django_db
-def test_add_consent_to_person(person):
-    consent = Consent.objects.create(
+@pytest.fixture()
+def consent() -> Consent:
+    return Consent.objects.create(
         type="it_guidelines",
         consent_name_en="IT Guidelines",
         consent_name_nb="IT Regelverk",
@@ -28,4 +31,25 @@ def test_add_consent_to_person(person):
         consent_link_en="https://example.org/it_guidelines",
         user_allowed_to_change=False,
     )
-    person.consents.add(consent, through_defaults={"consent_given_at": "2021-06-20"})
+
+
+@pytest.mark.django_db
+def test_add_consent_to_person(person: Person, consent: Consent):
+    consent_given_date = "2021-06-20"
+    person.consents.add(consent, through_defaults={"consent_given_at": consent_given_date})  # type: ignore
+    person_consent_links = PersonConsent.objects.filter(person_id=person.id)
+
+    assert len(person_consent_links) == 1
+    assert person_consent_links[0].person_id == person.id
+    assert person_consent_links[0].consent_id == consent.id
+    assert person_consent_links[0].consent_given_at == datetime.date(2021, 6, 20)
+
+
+@pytest.mark.django_db
+def test_add_not_acknowledged_consent_to_person(person: Person, consent: Consent):
+    person.consents.add(consent)
+    person_consent_links = PersonConsent.objects.filter(person_id=person.id)
+    assert len(person_consent_links) == 1
+    assert person_consent_links[0].person_id == person.id
+    assert person_consent_links[0].consent_id == consent.id
+    assert person_consent_links[0].consent_given_at is None
diff --git a/greg/tests/models/test_person.py b/greg/tests/models/test_person.py
index fb851f06594a6aa9e9e5996c52cb794f9ff3622d..87455763326c84b814830ceb84f00f52c9b869fa 100644
--- a/greg/tests/models/test_person.py
+++ b/greg/tests/models/test_person.py
@@ -1,18 +1,20 @@
+from datetime import (
+    datetime,
+    timedelta,
+)
 from functools import partial
 
 import pytest
 
-from django.db.models import ProtectedError
-
 from greg.models import (
     OrganizationalUnit,
     Person,
+    PersonIdentity,
     PersonRole,
     Role,
     Sponsor,
 )
 
-
 person_role_with = partial(
     PersonRole.objects.create,
     start_date="2020-03-05",
@@ -33,7 +35,7 @@ def role_bar() -> Role:
 
 
 @pytest.fixture
-def person(role_foo, role_bar) -> Person:
+def person(role_foo: Role, role_bar: Role) -> Person:
     person = Person.objects.create(
         first_name="Test",
         last_name="Tester",
@@ -59,6 +61,51 @@ def person(role_foo, role_bar) -> Person:
     return person
 
 
+@pytest.fixture
+def a_year_ago() -> datetime:
+    return datetime.now() - timedelta(days=365)
+
+
+@pytest.fixture
+def a_year_into_future() -> datetime:
+    return datetime.now() + timedelta(days=365)
+
+
+@pytest.fixture
+def feide_id(a_year_ago: datetime) -> PersonIdentity:
+    return PersonIdentity(
+        type=PersonIdentity.IdentityType.FEIDE_ID,
+        verified_when=a_year_ago,
+    )
+
+
+@pytest.fixture
+def passport(a_year_ago: datetime) -> PersonIdentity:
+    return PersonIdentity(
+        type=PersonIdentity.IdentityType.PASSPORT,
+        verified_when=a_year_ago,
+    )
+
+
+@pytest.fixture
+def unverified_passport() -> PersonIdentity:
+    return PersonIdentity(type=PersonIdentity.IdentityType.PASSPORT)
+
+
+@pytest.fixture
+def future_identity(a_year_into_future: datetime) -> PersonIdentity:
+    return PersonIdentity(
+        type=PersonIdentity.IdentityType.NATIONAL_ID_CARD,
+        verified_when=a_year_into_future,
+    )
+
+
+@pytest.fixture
+def feide_verified(person: Person, feide_id: PersonIdentity) -> Person:
+    person.identities.add(feide_id, bulk=False)
+    return person
+
+
 @pytest.mark.django_db
 def test_add_multiple_roles_to_person(person, role_foo, role_bar):
     person_roles = person.roles.all()
@@ -68,9 +115,63 @@ def test_add_multiple_roles_to_person(person, role_foo, role_bar):
 
 
 @pytest.mark.django_db
-def test_delete_person_with_roles(person):
-    # it is not clear what cleanup needs to be done when removing a person,
-    # so for now it is prohibited to delete a person with role relationships
-    # attached in other tables
-    with pytest.raises(ProtectedError):
-        person.delete()
+def test_identities(person: Person, feide_id: PersonIdentity, passport: PersonIdentity):
+    person.identities.add(feide_id, bulk=False)
+    assert list(person.identities.all()) == [feide_id]
+    person.identities.add(passport, bulk=False)
+    assert list(person.identities.all()) == [feide_id, passport]
+
+
+@pytest.mark.django_db
+def test_is_registered_incomplete(person):
+    assert person.registration_completed_date is None
+    assert not person.is_registered
+
+
+@pytest.mark.django_db
+def test_is_registered_completed_in_past(person, a_year_ago):
+    person.registration_completed_date = a_year_ago
+    assert person.is_registered
+
+
+@pytest.mark.django_db
+def test_is_registered_completed_in_future(person, a_year_into_future):
+    person.registration_completed_date = a_year_into_future
+    assert not person.is_registered
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("identity_type", PersonIdentity.IdentityType)
+def test_is_verified(identity_type, a_year_ago, person):
+    identity = PersonIdentity(type=identity_type, verified_when=a_year_ago)
+    person.identities.add(identity, bulk=False)
+    assert person.is_verified
+
+
+@pytest.mark.django_db
+def test_is_verified_multiple_identities(person, feide_id, passport):
+    person.identities.add(feide_id, passport, bulk=False)
+    assert person.is_verified
+
+
+@pytest.mark.django_db
+def is_verified_when_identity_is_unverified(person, unverified_passport):
+    person.identities.add(unverified_passport, bulk=False)
+    assert not person.is_verified
+
+
+@pytest.mark.django_db
+def is_verified_mixed_verified_and_unverified_identities(
+    person,
+    feide_id,
+    unverified_passport,
+    future_identity,
+):
+    person.identities.add(feide_id, unverified_passport, future_identity, bulk=False)
+    assert person.is_verified
+
+
+@pytest.mark.django_db
+def is_verified_in_future(person, future_identity):
+    person.identities.add(future_identity, bulk=False)
+    assert not person.is_verified
diff --git a/greg/tests/test_expire_role.py b/greg/tests/test_expire_role.py
new file mode 100644
index 0000000000000000000000000000000000000000..d116eb92c8342fc4019872cca27dad4e00254116
--- /dev/null
+++ b/greg/tests/test_expire_role.py
@@ -0,0 +1,94 @@
+from datetime import datetime, timedelta
+
+import pytest
+from django.db.models import Q
+from django.utils import timezone
+
+from greg.models import (
+    ScheduleTask,
+    Role,
+    Person,
+    OrganizationalUnit,
+    PersonRole,
+    Notification,
+    Sponsor,
+)
+from greg.schedule import ExpiringRolesNotification
+
+
+@pytest.fixture
+def role_task():
+    ScheduleTask.objects.create(
+        name="role_expiration", last_completed=timezone.now() - timedelta(days=1)
+    )
+
+
+@pytest.fixture
+def role_bar() -> Role:
+    return Role.objects.create(type="role_bar", name_en="Role Bar")
+
+
+@pytest.fixture
+def person(role_bar: Role) -> Person:
+    return Person.objects.create(
+        first_name="Test",
+        last_name="Tester",
+        date_of_birth="2000-01-27",
+        email="test@example.org",
+        mobile_phone="123456789",
+    )
+
+
+@pytest.fixture
+def organizational_unit() -> OrganizationalUnit:
+    return OrganizationalUnit.objects.create(orgreg_id="12345", name_en="Test unit")
+
+
+@pytest.fixture
+def sponsor() -> Sponsor:
+    return Sponsor.objects.create(feide_id="sponsor@example.org")
+
+
+@pytest.fixture
+def person_role(
+    person: Person,
+    role_bar: Role,
+    organizational_unit: OrganizationalUnit,
+    sponsor: Sponsor,
+) -> PersonRole:
+    return PersonRole.objects.create(
+        person=person,
+        role=role_bar,
+        unit=organizational_unit,
+        start_date="2020-03-05",
+        end_date=datetime.today() + timedelta(days=30),
+        contact_person_unit="Contact Person",
+        available_in_search=True,
+        registered_by=sponsor,
+    )
+
+
+@pytest.mark.django_db
+def test_role_picked_up(role_task: ScheduleTask, person_role: PersonRole):
+    role_notification = ExpiringRolesNotification()
+    assert len(Notification.objects.filter(~Q(operation="add"))) == 0
+    role_notification.run()
+    notification = Notification.objects.get(operation="expire_reminder")
+    assert notification.identifier == person_role.id
+    role_notification.run()
+    notifications = Notification.objects.filter(operation="expire_reminder")
+    assert len(notifications) == 1
+
+
+@pytest.mark.django_db
+def test_no_notification_for_role_not_about_to_expire(
+    role_task: ScheduleTask, person_role: PersonRole
+):
+    person_role.end_date = datetime.today() + timedelta(days=31)
+    person_role.save()
+    role_notification = ExpiringRolesNotification()
+    assert len(Notification.objects.filter(operation="expire_reminder")) == 0
+    # Role should not be picked up since it expires in 31 days
+    role_notification.run()
+    notifications = Notification.objects.filter(operation="expire_reminder")
+    assert len(notifications) == 0
diff --git a/greg/tests/test_notifications.py b/greg/tests/test_notifications.py
new file mode 100644
index 0000000000000000000000000000000000000000..584858f9337cbf6b6054c9ddce946ddb57095232
--- /dev/null
+++ b/greg/tests/test_notifications.py
@@ -0,0 +1,235 @@
+import pytest
+
+from greg.models import (
+    Person,
+    Notification,
+    Consent,
+    Role,
+    OrganizationalUnit,
+    Sponsor,
+    PersonConsent,
+    PersonIdentity,
+)
+
+
+@pytest.fixture
+def person() -> Person:
+    return Person.objects.create(
+        first_name="Test",
+        last_name="Tester",
+        date_of_birth="2000-01-27",
+        email="test@example.org",
+        mobile_phone="123456789",
+    )
+
+
+@pytest.fixture
+def role_foo() -> Role:
+    return Role.objects.create(type="role_foo", name_en="Role Foo")
+
+
+@pytest.fixture
+def org_unit_bar() -> OrganizationalUnit:
+    return OrganizationalUnit.objects.create(orgreg_id="bar_unit")
+
+
+@pytest.fixture
+def sponsor() -> Sponsor:
+    return Sponsor.objects.create(feide_id="sponsor_id")
+
+
+@pytest.fixture
+def consent() -> Consent:
+    return Consent.objects.create(
+        type="it_guidelines",
+        consent_name_en="IT Guidelines",
+        consent_name_nb="IT Regelverk",
+        consent_description_en="IT Guidelines description",
+        consent_description_nb="IT Regelverk beskrivelse",
+        consent_link_en="https://example.org/it_guidelines",
+        user_allowed_to_change=False,
+    )
+
+
+@pytest.fixture
+def person_identity(person: Person) -> PersonIdentity:
+    return PersonIdentity.objects.create(
+        person=person,
+        type=PersonIdentity.IdentityType.PASSPORT,
+        source="Test",
+        value="12345678901",
+    )
+
+
+@pytest.mark.django_db
+def test_person_add_notification(person: Person):
+    notifications = Notification.objects.filter(object_type="Person")
+    assert len(notifications) == 1
+    assert notifications[0].operation == "add"
+    assert notifications[0].identifier == person.id
+
+
+@pytest.mark.django_db
+def test_person_update_notification(person: Person):
+    person.first_name = "New first name"
+    person.save()
+    notifications = Notification.objects.filter(object_type="Person")
+    assert len(notifications) == 2
+    assert notifications[1].operation == "update"
+    assert notifications[1].identifier == person.id
+
+
+@pytest.mark.django_db
+def test_person_delete_notification(person: Person):
+    person_id = person.id
+    person.delete()
+    notifications = Notification.objects.filter(object_type="Person")
+    assert len(notifications) == 2
+    assert notifications[1].operation == "delete"
+    assert notifications[1].identifier == person_id
+
+
+@pytest.mark.django_db
+def test_role_add_notification(
+    person: Person, role_foo: Role, org_unit_bar: OrganizationalUnit, sponsor: Sponsor
+):
+    person.roles.add(  # type: ignore
+        role_foo,
+        through_defaults={
+            "start_date": "2021-05-06",
+            "end_date": "2021-10-20",
+            "unit": org_unit_bar,
+            "registered_by": sponsor,
+        },
+    )
+    notifications = Notification.objects.filter(object_type="PersonRole")
+    assert len(notifications) == 1
+    assert notifications[0].operation == "add"
+    meta_data = notifications[0].meta
+    assert meta_data["person_id"] == person.id
+    assert meta_data["role_id"] == role_foo.id
+
+
+@pytest.mark.django_db
+def test_role_update_notification(
+    person: Person, role_foo: Role, org_unit_bar: OrganizationalUnit, sponsor: Sponsor
+):
+    person.roles.add(  # type: ignore
+        role_foo,
+        through_defaults={
+            "start_date": "2021-05-06",
+            "end_date": "2021-10-20",
+            "unit": org_unit_bar,
+            "registered_by": sponsor,
+        },
+    )
+
+    assert len(person.person_roles.all()) == 1
+    person_role = person.person_roles.all()[0]
+    person_role.end_date = "2021-10-21"
+    person_role.save()
+    notifications = Notification.objects.filter(object_type="PersonRole")
+    assert len(notifications) == 2
+    assert notifications[1].operation == "update"
+    meta_data = notifications[1].meta
+    assert meta_data["person_id"] == person.id
+    assert meta_data["role_id"] == role_foo.id
+
+
+@pytest.mark.django_db
+def test_role_delete_notification(
+    person: Person, role_foo: Role, org_unit_bar: OrganizationalUnit, sponsor: Sponsor
+):
+    person.roles.add(  # type: ignore
+        role_foo,
+        through_defaults={
+            "start_date": "2021-05-06",
+            "end_date": "2021-10-20",
+            "unit": org_unit_bar,
+            "registered_by": sponsor,
+        },
+    )
+
+    assert len(person.person_roles.all()) == 1
+    person_role = person.person_roles.all()[0]
+    person_role.delete()
+    notifications = Notification.objects.filter(object_type="PersonRole")
+    assert len(notifications) == 2
+    assert notifications[1].operation == "delete"
+    meta_data = notifications[1].meta
+    assert meta_data["person_id"] == person.id
+    assert meta_data["role_id"] == role_foo.id
+
+
+@pytest.mark.django_db
+def test_consent_add_notification(person: Person, consent: Consent):
+    person.consents.add(consent, through_defaults={"consent_given_at": "2021-06-20"})  # type: ignore
+    notifications = Notification.objects.filter(object_type="PersonConsent")
+    assert len(notifications) == 1
+    assert notifications[0].identifier == person.id
+    meta_data = notifications[0].meta
+    assert meta_data["person_id"] == person.id
+    assert meta_data["consent_id"] == consent.id
+
+
+@pytest.mark.django_db
+def test_consent_update_notification(person: Person, consent: Consent):
+    person.consents.add(consent, through_defaults={"consent_given_at": "2021-06-20"})  # type: ignore
+    person_consents = PersonConsent.objects.filter(person=person, consent=consent)
+    person_consents[0].consent_given_at = "2021-06-21"
+    person_consents[0].save()
+
+    notifications = Notification.objects.filter(object_type="PersonConsent")
+    assert len(notifications) == 2
+    assert notifications[0].identifier == person.id
+    meta_data = notifications[0].meta
+    assert meta_data["person_id"] == person.id
+    assert meta_data["consent_id"] == consent.id
+
+
+@pytest.mark.django_db
+def test_consent_delete_notification(person: Person, consent: Consent):
+    person.consents.add(consent, through_defaults={"consent_given_at": "2021-06-20"})  # type: ignore
+    person_consents = PersonConsent.objects.filter(person=person, consent=consent)
+    person_consents[0].delete()
+    notifications = Notification.objects.filter(object_type="PersonConsent")
+
+    assert len(notifications) == 2
+    assert notifications[1].identifier == person.id
+    assert notifications[1].operation == "delete"
+    meta_data = notifications[0].meta
+    assert meta_data["person_id"] == person.id
+    assert meta_data["consent_id"] == consent.id
+
+
+@pytest.mark.django_db
+def test_person_identity_add_notification(
+    person: Person, person_identity: PersonIdentity, sponsor: Sponsor
+):
+    notifications = Notification.objects.filter(object_type="PersonIdentity")
+    assert len(notifications) == 1
+    assert notifications[0].identifier == person.id
+    assert notifications[0].operation == "add"
+
+    meta_data = notifications[0].meta
+    assert meta_data["person_id"] == person.id
+    assert meta_data["identity_id"] == person_identity.id
+
+
+@pytest.mark.django_db
+def test_person_identity_update_notification(
+    person: Person, person_identity: PersonIdentity, sponsor: Sponsor
+):
+    person_identity.verified = PersonIdentity.Verified.MANUAL
+    person_identity.verified_by = sponsor
+    person_identity.verified_when = "2021-08-02"
+    person_identity.save()
+
+    notifications = Notification.objects.filter(object_type="PersonIdentity")
+    # One notification for adding person identity and one for updating it
+    assert len(notifications) == 2
+    assert notifications[1].operation == "update"
+
+    meta_data = notifications[1].meta
+    assert meta_data["person_id"] == person.id
+    assert meta_data["identity_id"] == person_identity.id
diff --git a/greg/urls.py b/greg/urls.py
index aae406a6ca1128569d8b7efd1140e97e1b912da4..b378dd77e8b70f451eedd24b350543662a8c8c71 100644
--- a/greg/urls.py
+++ b/greg/urls.py
@@ -16,9 +16,25 @@ Including another URLconf
 from typing import List
 from django.urls import path, include
 from django.urls.resolvers import URLResolver
+from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
+from rest_framework.versioning import NamespaceVersioning
 
 from greg.api import urls as api_urls
+from greg.api.views.health import Health
 
 urlpatterns: List[URLResolver] = [
-    path("api/", include(api_urls.urlpatterns)),
+    path(
+        "schema/",
+        SpectacularAPIView.as_view(versioning_class=NamespaceVersioning),
+        name="schema",
+    ),  # type: ignore
+    path(
+        "schema/swagger-ui/",
+        SpectacularSwaggerView.as_view(
+            url_name="schema", versioning_class=NamespaceVersioning
+        ),  # type: ignore
+        name="swagger-ui",
+    ),
+    path("health/", Health.as_view()),  # type: ignore
+    path("api/v1/", include((api_urls.urlpatterns, "greg"), namespace="v1")),
 ]
diff --git a/gregsite/settings/base.py b/gregsite/settings/base.py
index 3c1521debe953d0b0a76fce59ae7d46a1329ba82..b80718ce8c7e74da5e1835aafed752d5713a01e3 100644
--- a/gregsite/settings/base.py
+++ b/gregsite/settings/base.py
@@ -60,6 +60,9 @@ MIDDLEWARE = [
 ]
 
 REST_FRAMEWORK = {
+    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning",
+    "DEFAULT_VERSION": "v1",
+    "ALLOWED_VERSIONS": ("v1",),
     "DEFAULT_AUTHENTICATION_CLASSES": (
         "rest_framework.authentication.TokenAuthentication",
         "rest_framework.authentication.SessionAuthentication",
@@ -208,6 +211,10 @@ NOTIFICATION_PUBLISHER = {
     "daemon": {"pid_file": "/tmp/greg_notification_publisher.lock", "poll_interval": 1},
 }
 
+SCHEDULE_TASKS = {
+    "daemon": {"pid_file": "/tmp/schedule_tasks.lock"},
+}
+
 
 INSTANCE_NAME = "local"
 INTERNAL_RK_PREFIX = "no.{instance}.greg".format(instance=INSTANCE_NAME)