diff --git a/frontend/src/routes/sponsor/frontpage/index.tsx b/frontend/src/routes/sponsor/frontpage/index.tsx index bb48c68717f027026f98f5e5a6e324dc6a273cf7..43057ae2f9d677998fd62396a99d471d6a697403 100644 --- a/frontend/src/routes/sponsor/frontpage/index.tsx +++ b/frontend/src/routes/sponsor/frontpage/index.tsx @@ -151,7 +151,7 @@ function FrontPage() { const [t] = useTranslation(['common']) const fetchGuestsInfo = async () => { - const response = await fetch('/api/ui/v1/persons/?format=json') + const response = await fetch('/api/ui/v1/guests/?format=json') const jsonResponse = await response.json() if (response.ok) { const roles = await jsonResponse.roles diff --git a/greg/admin.py b/greg/admin.py index fa833b233cf117ba14267a59299ea508668567e0..293f4973132a78dc527575442ff74ddf6ff097c1 100644 --- a/greg/admin.py +++ b/greg/admin.py @@ -2,6 +2,8 @@ from django.contrib import admin from reversion.admin import VersionAdmin from greg.models import ( + Invitation, + InvitationLink, Person, Role, RoleType, @@ -110,6 +112,15 @@ class SponsorOrganizationalUnitAdmin(VersionAdmin): readonly_fields = ("id", "created", "updated") +class InvitationAdmin(VersionAdmin): + list_display = ("id",) + + +class InvitationLinkAdmin(VersionAdmin): + list_display = ("uuid", "invitation", "created", "expire") + readonly_fields = ("uuid",) + + admin.site.register(Person, PersonAdmin) admin.site.register(Role, RoleAdmin) admin.site.register(RoleType, RoleTypeAdmin) @@ -119,3 +130,5 @@ admin.site.register(ConsentType, ConsentTypeAdmin) admin.site.register(OrganizationalUnit, OrganizationalUnitAdmin) admin.site.register(Sponsor, SponsorAdmin) admin.site.register(SponsorOrganizationalUnit, SponsorOrganizationalUnitAdmin) +admin.site.register(Invitation, InvitationAdmin) +admin.site.register(InvitationLink, InvitationLinkAdmin) diff --git a/greg/api/serializers/person.py b/greg/api/serializers/person.py index d1e63341ddfb7cf8cdc471042369589a9bcfe17c..0bfa97c4b762bd3db385082d5257d93bd7837d52 100644 --- a/greg/api/serializers/person.py +++ b/greg/api/serializers/person.py @@ -81,7 +81,6 @@ class PersonSerializer(serializers.ModelSerializer): "mobile_phone", "mobile_phone_verified_date", "registration_completed_date", - "token", "identities", "roles", "consents", diff --git a/greg/migrations/0008_add_invitations.py b/greg/migrations/0008_add_invitations.py new file mode 100644 index 0000000000000000000000000000000000000000..1ca0a0b0a5d240bdcff3dc24508b3acbac304d5e --- /dev/null +++ b/greg/migrations/0008_add_invitations.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2.7 on 2021-10-06 08:37 + +import dirtyfields.dirtyfields +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('greg', '0007_alter_organizationalunit_parent'), + ] + + operations = [ + migrations.CreateModel( + name='Invitation', + fields=[ + ('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)), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='greg.role')), + ], + options={ + 'abstract': False, + }, + bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), + ), + migrations.RemoveField( + model_name='person', + name='token', + ), + migrations.CreateModel( + name='InvitationLink', + fields=[ + ('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)), + ('uuid', models.UUIDField(default=uuid.uuid4)), + ('expire', models.DateTimeField()), + ('invitation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='greg.invitation')), + ], + options={ + 'abstract': False, + }, + bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), + ), + ] diff --git a/greg/models.py b/greg/models.py index 89771529f99dadc6ba5be9cb8e57bb4cb09005c1..ec14ef9756d9bd98d12464b3d6224279c6bdc9be 100644 --- a/greg/models.py +++ b/greg/models.py @@ -1,3 +1,5 @@ +import uuid + from datetime import date from dirtyfields import DirtyFieldsMixin @@ -42,7 +44,6 @@ class Person(BaseModel): 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) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, @@ -418,3 +419,28 @@ class ScheduleTask(models.Model): return "{}(id={!r}, name={!r}, last_completed={!r})".format( self.__class__.__name__, self.pk, self.name, self.last_completed ) + + +class InvitationLink(BaseModel): + """ + Link to an invitation. + + Having the uuid of an InvitationLink should grant access to the view for posting + If the Invitation itself is deleted, all InvitationLinks are also be removed. + """ + + uuid = models.UUIDField(null=False, default=uuid.uuid4, blank=False) + invitation = models.ForeignKey( + "Invitation", on_delete=models.CASCADE, null=False, blank=False + ) + expire = models.DateTimeField(blank=False, null=False) + + +class Invitation(BaseModel): + """ + Stores information about an invitation. + + Deleting the InvitedPerson deletes the Invitation. + """ + + role = models.ForeignKey("Role", null=False, blank=False, on_delete=models.CASCADE) diff --git a/gregsite/settings/dev.py b/gregsite/settings/dev.py index 6cc23c5aa1f99a96b5e389400fb16703e3c0bf1b..403f1f10fe320a20fa8d60ae77d9cc80b866cb0f 100644 --- a/gregsite/settings/dev.py +++ b/gregsite/settings/dev.py @@ -15,12 +15,6 @@ ORGREG_CLIENT = { "headers": {"X-Gravitee-Api-Key": "bar"}, } -try: - from .local import * -except ImportError: - pass - - AUTHENTICATION_BACKENDS = [ "gregui.authentication.auth_backends.DevBackend", # Fake dev backend "django.contrib.auth.backends.ModelBackend", # default @@ -35,3 +29,8 @@ CSRF_COOKIE_SAMESITE = "Strict" SESSION_COOKIE_SAMESITE = "Lax" # CSRF_COOKIE_HTTPONLY = True # SESSION_COOKIE_HTTPONLY = True + +try: + from .local import * +except ImportError: + pass diff --git a/gregui/api/serializers/invitation.py b/gregui/api/serializers/invitation.py new file mode 100644 index 0000000000000000000000000000000000000000..bbb5a5cae80bef0a58e08689a840b8b78a35d016 --- /dev/null +++ b/gregui/api/serializers/invitation.py @@ -0,0 +1,40 @@ +import datetime + +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers + +from greg.models import Invitation, InvitationLink, Person, Role +from gregui.api.serializers.role import RoleSerializerUi +from gregui.models import GregUserProfile + + +class InviteGuestSerializer(serializers.ModelSerializer): + role = RoleSerializerUi() + uuid = serializers.UUIDField(read_only=True) + + def create(self, validated_data): + role_data = validated_data.pop("role") + + user = GregUserProfile.objects.get(user=self.context["request"].user) + + # Create objects + with transaction.atomic(): + person = Person.objects.create(**validated_data) + role_data["person"] = person + role_data["sponsor_id"] = user.sponsor + role = Role.objects.create(**role_data) + invitation = Invitation.objects.create(role=role) + InvitationLink.objects.create( + invitation=invitation, + expire=timezone.now() + datetime.timedelta(days=30), + ) + return person + + class Meta: + model = Person + fields = ("id", "first_name", "last_name", "date_of_birth", "role", "uuid") + read_only_field = ("uuid",) + + +foo = InviteGuestSerializer() diff --git a/gregui/api/serializers/role.py b/gregui/api/serializers/role.py new file mode 100644 index 0000000000000000000000000000000000000000..1946e6ea9ba9b4412baa5295a936f7df26deccd1 --- /dev/null +++ b/gregui/api/serializers/role.py @@ -0,0 +1,17 @@ +from rest_framework.serializers import ModelSerializer + +from greg.models import Role + + +class RoleSerializerUi(ModelSerializer): + class Meta: + model = Role + fields = [ + "orgunit_id", + "start_date", + "type", + "end_date", + "contact_person_unit", + "comments", + "available_in_search", + ] diff --git a/gregui/api/urls.py b/gregui/api/urls.py index baa5f34956ee7a4f81da04c42d3bed8272cdaaf6..064d0077485326015a084f5459e02f69f688bc72 100644 --- a/gregui/api/urls.py +++ b/gregui/api/urls.py @@ -1,8 +1,13 @@ -from django.urls import re_path +from django.urls import re_path, path from rest_framework.routers import DefaultRouter from gregui.api.views.guest import GuestRegisterView +from gregui.api.views.invitation import ( + CheckInvitationView, + CreateInvitationView, + InvitedGuestView, +) from gregui.api.views.roletypes import RoleTypeViewSet from gregui.api.views.unit import UnitsViewSet @@ -13,4 +18,7 @@ urlpatterns += [ re_path(r"register/$", GuestRegisterView.as_view(), name="guest-register"), re_path(r"roletypes/$", RoleTypeViewSet.as_view(), name="role-types"), re_path(r"units/$", UnitsViewSet.as_view(), name="units"), + path("invited/", InvitedGuestView.as_view(), name="invite"), + path("invited/<uuid>", CheckInvitationView.as_view()), + path("invite/", CreateInvitationView.as_view()), ] diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py new file mode 100644 index 0000000000000000000000000000000000000000..1c0320741a7beaafcf1066b45f42fca70add8655 --- /dev/null +++ b/gregui/api/views/invitation.py @@ -0,0 +1,200 @@ +import json +import datetime +from uuid import uuid4 +from django.core import exceptions +from django.db import transaction +from django.http.response import JsonResponse + +from django.utils import timezone +from rest_framework import serializers, status +from rest_framework.authentication import SessionAuthentication, BasicAuthentication +from rest_framework.generics import CreateAPIView +from rest_framework.parsers import JSONParser +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from greg.models import Identity, Invitation, InvitationLink, Person, Role, Sponsor +from greg.permissions import IsSponsor +from gregui.api.serializers.guest import GuestRegisterSerializer +from gregui.api.serializers.invitation import InviteGuestSerializer + + +from gregui.models import GregUserProfile + + +class CreateInvitationView(CreateAPIView): + """ + Invitation creation endpoint + + + { + "first_name": "dfff", + "last_name": "sss", + "date_of_birth": null, + "role": { + "orgunit_id": 1, + "start_date": null, + "type": 1, + "end_date": "2021-12-15", + "contact_person_unit": "", + "comments": "", + "available_in_search": false + } + } + """ + + authentication_classes = [BasicAuthentication, SessionAuthentication] + permission_classes = [IsSponsor] + parser_classes = [JSONParser] + serializer_class = InviteGuestSerializer + + def post(self, request, *args, **kwargs) -> Response: + """ + Invitation creation endpoint + + Restricted to Sponsors, and a sponsor can only invite guests to OUs + they are registered to. + + The next step in the flow is for the guest to use their link, review the + information, and confirm it. + """ + sponsor_user = GregUserProfile.objects.get(user=request.user) + serializer = self.serializer_class( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + person = serializer.save() + + invitationlink = InvitationLink.objects.filter( + invitation__person=person.id, + invitation__role__sponsor_id=sponsor_user.sponsor, + ) + # TODO: send email to invited guest + print(invitationlink) + return Response(status=status.HTTP_201_CREATED) + + +class CheckInvitationView(APIView): + authentication_classes = [] + permission_classes = [AllowAny] + + def get(self, request, *args, **kwargs): + """ + Endpoint for verifying and setting invite_id in session. + + This endpoint is meant to be called in the background by the frontend on the + page you get to by following the invitation url to the frontend. This way a + session is created keeping the invite id safe, until the user returns from + feide login if they choose to use it. + """ + invite_id = kwargs["uuid"] + try: + invite_link = InvitationLink.objects.get(uuid=invite_id) + except (InvitationLink.DoesNotExist, exceptions.ValidationError): + return Response(status=status.HTTP_403_FORBIDDEN) + if invite_link.expire <= timezone.now(): + return Response(status=status.HTTP_403_FORBIDDEN) + request.session["invite_id"] = invite_id + return Response(status=status.HTTP_200_OK) + + +class InvitedGuestView(APIView): + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [AllowAny] + parser_classes = [JSONParser] + serializer_class = GuestRegisterSerializer + + def get(self, request, *args, **kwargs): + """ + Endpoint for fetching data related to an invite + + Used by the frontend for fetching data from the backend for review by a guest + in the frontend, before calling the post endpoint defined below with updated + info and confirmation. + """ + invite_id = request.session.get("invite_id") + try: + invite_link = InvitationLink.objects.get(uuid=invite_id) + except (InvitationLink.DoesNotExist, exceptions.ValidationError): + return Response(status=status.HTTP_403_FORBIDDEN) + if invite_link.expire <= timezone.now(): + return Response(status=status.HTTP_403_FORBIDDEN) + + # if invite_id: + invite_link = InvitationLink.objects.get(uuid=invite_id) + role = invite_link.invitation.role + person = role.person + sponsor = role.sponsor_id + + try: + fnr = person.identities.get(type="norwegian_national_id_number").value + except Identity.DoesNotExist: + fnr = None + try: + passport = person.identities.get(type="passport_number").value + except Identity.DoesNotExist: + passport = None + + data = { + "person": { + "first_name": person.first_name, + "last_name": person.last_name, + "email": person.email, + "mobile_phone": person.mobile_phone, + "fnr": fnr, + "passport": passport, + }, + "sponsor": { + "first_name": sponsor.first_name, + "last_name": sponsor.last_name, + }, + "role": { + "ou_name_nb": role.orgunit_id.name_nb, + "ou_name_en": role.orgunit_id.name_en, + "role_name_nb": role.type.name_nb, + "role_name_en": role.type.name_en, + "start": role.start_date, + "end": role.end_date, + "comments": role.comments, + }, + } + return JsonResponse(data=data, status=status.HTTP_200_OK) + + def post(self, request, *args, **kwargs): + """ + Endpoint for confirmation of data updated by guest + + Used by frontend when the confirm button at the end of the review flow is + clicked by the user. The next part of the flow is for the sponsor to confirm + the guest. + """ + invite_id = kwargs["uuid"] + data = request.data + + with transaction.atomic(): + # Ensure the invitation link is valid and not expired + try: + invite_link = InvitationLink.objects.get(uuid=invite_id) + except (InvitationLink.DoesNotExist, exceptions.ValidationError): + return Response(status=status.HTTP_403_FORBIDDEN) + if invite_link.expire <= timezone.now(): + return Response(status=status.HTTP_403_FORBIDDEN) + + # Get objects to update + person = invite_link.invitation.role.person + + # Update with input from the guest + mobile = data.get("mobile_phone") + if mobile: + person.mobile_phone = data["mobile_phone"] + + # Mark guest interaction done + person.registration_completed_date = timezone.now().date() + person.save() + + # Expire the invite link + invite_link.expire = timezone.now() + invite_link.save() + # TODO: Send an email to the sponsor? + return Response(status=status.HTTP_201_CREATED) diff --git a/gregui/api/views/userinfo.py b/gregui/api/views/userinfo.py index ad81b03c4b18314a2e29082ba7c0a5dc6c33aa89..8abf0c9a0cc83eaf56b270583f3c1a3c9e93259a 100644 --- a/gregui/api/views/userinfo.py +++ b/gregui/api/views/userinfo.py @@ -5,9 +5,11 @@ from typing import ( from rest_framework import permissions from rest_framework.authentication import BaseAuthentication, SessionAuthentication -from rest_framework.permissions import BasePermission +from rest_framework.permissions import AllowAny, BasePermission +from rest_framework.status import HTTP_403_FORBIDDEN from rest_framework.views import APIView from rest_framework.response import Response +from greg.models import Identity, InvitationLink from gregui.models import GregUserProfile @@ -21,26 +23,111 @@ class UserInfoView(APIView): """ authentication_classes: Sequence[Type[BaseAuthentication]] = [SessionAuthentication] - permission_classes: Sequence[Type[BasePermission]] = [permissions.IsAuthenticated] + permission_classes: Sequence[Type[BasePermission]] = [AllowAny] def get(self, request, format=None): + """ + Get info about the visiting user + + Works for users logged in using Feide, and those relying solely on an + invitation id. + + TODO: Can this be modified into a permission class to reduce clutter? + """ user = request.user + invite_id = request.session.get("invite_id") - user_profile = GregUserProfile.objects.get(user=user) + # Authenticated user, allow access + if user.is_authenticated: + user_profile = GregUserProfile.objects.get(user=user) + sponsor_id = None + person_id = None + if user_profile.sponsor: + sponsor_id = user_profile.sponsor.id + if user_profile.person: + person_id = user_profile.person.id + content = { + "feide_id": user_profile.userid_feide, + "sponsor_id": sponsor_id, + "person_id": person_id, + } + person = user_profile.person + roles = person.roles + if person: + content.update( + { + "first_name": person.first_name, + "last_name": person.last_name, + "email": person.email, + "mobile_phone": person.mobile_phone, + } + ) + if roles: + content.update( + { + "roles": [ + { + "ou_name_nb": role.orgunit_id.name_nb, + "ou_name_en": role.orgunit_id.name_en, + "role_name_nb": role.type.name_nb, + "role_name_en": role.type.name_en, + "start": role.start_date, + "end": role.end_date, + "comments": role.comments, + "sponsor": { + "first_name": role.sponsor_id.first_name, + "last_name": role.sponsor_id.last_name, + }, + } + for role in roles.all() + ], + } + ) + return Response(content) - sponsor_id = None - person_id = None - if user_profile.sponsor: - sponsor_id = user_profile.sponsor.id + # Invitation cookie, allow access + elif invite_id: + link = InvitationLink.objects.get(uuid=invite_id) + invitation = link.invitation + person = invitation.role.person + roles = person.roles + try: + fnr = person.identities.get(type="norwegian_national_id_number").value + except Identity.DoesNotExist: + fnr = None + try: + passport = person.identities.get(type="passport_number").value + except Identity.DoesNotExist: + passport = None - if user_profile.person: - person_id = user_profile.person.id + content = { + "feide_id": None, + "first_name": person.first_name, + "last_name": person.last_name, + "email": person.email, + "mobile_phone": person.mobile_phone, + "fnr": fnr, + "passport": passport, + "roles": [ + { + "ou_name_nb": role.orgunit_id.name_nb, + "ou_name_en": role.orgunit_id.name_en, + "role_name_nb": role.type.name_nb, + "role_name_en": role.type.name_en, + "start": role.start_date, + "end": role.end_date, + "comments": role.comments, + "sponsor": { + "first_name": role.sponsor_id.first_name, + "last_name": role.sponsor_id.last_name, + }, + } + for role in roles.all() + ], + } - content = { - "feide_id": user_profile.userid_feide, - "name": f"{user.first_name} {user.last_name}", - "sponsor_id": sponsor_id, - "person_id": person_id, - } + return Response(content) - return Response(content) + # Neither, deny access + else: + return Response(status=HTTP_403_FORBIDDEN) diff --git a/gregui/urls.py b/gregui/urls.py index e36ae3922e44d87ce6caa0b8afdff5847e4cbe53..56e17df61e6251dd7d569ba4d0a64892b440298c 100644 --- a/gregui/urls.py +++ b/gregui/urls.py @@ -6,7 +6,7 @@ from django.urls.resolvers import URLResolver from gregui.api import urls as api_urls from gregui.api.views.userinfo import UserInfoView -from gregui.views import OusView, PersonInfoView, TokenCreationView +from gregui.views import OusView, GuestInfoView from . import views urlpatterns: List[URLResolver] = [ @@ -18,8 +18,7 @@ urlpatterns: List[URLResolver] = [ path("api/ui/v1/login/", views.login_view, name="api-login"), path("api/ui/v1/session/", views.SessionView.as_view(), name="api-session"), path("api/ui/v1/whoami/", views.WhoAmIView.as_view(), name="api-whoami"), - path("api/ui/v1/token/<email>", TokenCreationView.as_view()), path("api/ui/v1/userinfo/", UserInfoView.as_view()), # type: ignore path("api/ui/v1/ous/", OusView.as_view()), - path("api/ui/v1/persons/", PersonInfoView.as_view()), + path("api/ui/v1/guests/", GuestInfoView.as_view()), ] diff --git a/gregui/views.py b/gregui/views.py index 14023cd4e213c14bbc0f6f2746497e613bf79c5d..0801eda1a1f60441677b32e47484ec1970e1b1fd 100644 --- a/gregui/views.py +++ b/gregui/views.py @@ -1,67 +1,16 @@ -from django.contrib.auth import get_user_model from django.contrib.auth import logout from django.http import JsonResponse from django.middleware.csrf import get_token from django.shortcuts import redirect -from rest_framework import status from rest_framework.authentication import SessionAuthentication, BasicAuthentication from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response from rest_framework.views import APIView -from sesame.utils import get_query_string -from greg.models import Person, Role, Sponsor +from greg.models import Role, Sponsor from greg.permissions import IsSponsor from gregui.models import GregUserProfile -class TokenCreationView(APIView): - """Token creation endpoint""" - - # Allow anyone to request a new query string to be sent to them - permission_classes = [] - - def _get_username(self, user_model, person: Person) -> str: - """Find a free username in the database for a person""" - counter = 1 - while True: - username = person.first_name[:3] + person.last_name[:3] + str(counter) - if not user_model.objects.filter(username=username).exists(): - return username - counter += 1 - - def post(self, request, *args, **kwargs): - """ - Send email to Person with querystring for login - - Persons without a user will have one created for them. - """ - email = self.kwargs["email"] - try: - person = Person.objects.get(email=email) - except Person.DoesNotExist: - # Exit if no person with that email (make sure to exit same way as when - # person does exist to not leak information) - return Response(status=status.HTTP_200_OK) - - # Create user if person does not have one or fetch existing - if not person.user: - user_model = get_user_model() - username = self._get_username(user_model, person) - user = user_model.objects.create(username=username) - person.user = user - person.save() - else: - user = person.user - - # Create querystring and send email - querystring = get_query_string(user) - # TODO: send email with query string - print(querystring) - - return Response(status=status.HTTP_200_OK) - - def get_csrf(request): response = JsonResponse({"detail": "CSRF cookie set"}) response["X-CSRFToken"] = get_token(request) @@ -129,7 +78,7 @@ class OusView(APIView): ) -class PersonInfoView(APIView): +class GuestInfoView(APIView): authentication_classes = [SessionAuthentication, BasicAuthentication] permission_classes = [IsAuthenticated, IsSponsor]