diff --git a/greg/api/filters.py b/greg/api/filters.py index 14ef355eb0bc7935dc090f5cf189055e2df78765..c7af546c6b456316345e0f4977fc2a1675cec7e9 100644 --- a/greg/api/filters.py +++ b/greg/api/filters.py @@ -1,19 +1,27 @@ -from django_filters import rest_framework as filters +from django_filters.rest_framework import ( + BaseInFilter, + BooleanFilter, + FilterSet, +) -from greg.models import Person, PersonRole, PersonIdentity +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: @@ -21,7 +29,7 @@ class PersonFilter(filters.FilterSet): fields = ["first_name", "last_name", "verified"] -class PersonIdentityFilter(filters.FilterSet): +class PersonIdentityFilter(FilterSet): class Meta: model = PersonIdentity fields = ["type", "verified_by_id"] diff --git a/greg/api/views/person.py b/greg/api/views/person.py index 40f9c8d07fc1b8cb556046aa3536f3e21029cf5d..bb58842992f4afc293d90a6f7457388046579382 100644 --- a/greg/api/views/person.py +++ b/greg/api/views/person.py @@ -36,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): diff --git a/greg/models.py b/greg/models.py index f3ea02a00daaf67ea465e20d532059025eb9d9f2..f50684c81784f6c226c99befc88de038603ad037 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 @@ -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.""" @@ -155,7 +217,7 @@ class PersonIdentity(BaseModel): MANUAL = "MANUAL" person = models.ForeignKey( - "Person", on_delete=models.CASCADE, related_name="person" + "Person", on_delete=models.CASCADE, related_name="identities" ) type = models.CharField(max_length=18, choices=IdentityType.choices) source = models.CharField(max_length=256) @@ -167,15 +229,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, ) diff --git a/greg/tests/models/test_person.py b/greg/tests/models/test_person.py index f4aeba5820a90b2ce592f87e6cf1e124b81c2d12..87455763326c84b814830ceb84f00f52c9b869fa 100644 --- a/greg/tests/models/test_person.py +++ b/greg/tests/models/test_person.py @@ -1,3 +1,7 @@ +from datetime import ( + datetime, + timedelta, +) from functools import partial import pytest @@ -5,6 +9,7 @@ import pytest from greg.models import ( OrganizationalUnit, Person, + PersonIdentity, PersonRole, Role, Sponsor, @@ -30,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", @@ -56,9 +61,117 @@ 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() assert len(person_roles) == 2 assert role_foo in person_roles assert role_bar in person_roles + + +@pytest.mark.django_db +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