Skip to content
Snippets Groups Projects
Commit ad4bf7cd authored by Andreas Ellewsen's avatar Andreas Ellewsen
Browse files

Merge branch 'GREG-62-invitations' into 'master'

Add invitations

See merge request !85
parents 4569fc42 850fd3c0
No related branches found
No related tags found
1 merge request!85Add invitations
Pipeline #95985 passed
Showing
with 532 additions and 93 deletions
......@@ -2,7 +2,8 @@ import { createContext, useContext } from 'react'
interface User {
auth: boolean
name: string
first_name: string
last_name: string
}
interface IUserContext {
......@@ -14,7 +15,7 @@ interface IUserContext {
function noop() {}
export const UserContext = createContext<IUserContext>({
user: { auth: false, name: '' },
user: { auth: false, first_name: '', last_name: '' },
fetchUserInfo: noop,
clearUserInfo: noop,
})
......
......@@ -8,7 +8,11 @@ type UserProviderProps = {
function UserProvider(props: UserProviderProps) {
const { children } = props
const [user, setUser] = useState({ auth: false, name: '' })
const [user, setUser] = useState({
auth: false,
first_name: '',
last_name: '',
})
const [fetching, setFetching] = useState(false)
const getUserInfo = async () => {
......@@ -17,7 +21,11 @@ function UserProvider(props: UserProviderProps) {
const data = await response.json()
if (response.ok) {
setUser({ auth: true, name: data.name })
setUser({
auth: true,
first_name: data.first_name,
last_name: data.last_name,
})
}
} catch (error) {
// Do nothing
......@@ -33,7 +41,7 @@ function UserProvider(props: UserProviderProps) {
}
const clearUserInfo = () => {
setUser({ auth: false, name: '' })
setUser({ auth: false, first_name: '', last_name: '' })
}
return (
......
......@@ -10,7 +10,11 @@ function UserInfo() {
}, [])
if (user.auth) {
return <div>{user.name}</div>
return (
<div>
{user.first_name} {user.last_name}
</div>
)
}
return <></>
}
......
......@@ -14,7 +14,9 @@ export default function FrontPage() {
<Page>
<p>
<strong>Routes</strong>
<p>{user.name}</p>
<p>
{user.first_name} {user.last_name}
</p>
<ul>
<li>
<Link to="/">Front page</Link>
......
......@@ -10,7 +10,8 @@ import { useUserContext } from 'contexts'
import Sponsor from 'routes/sponsor'
import Register from 'routes/register'
import FrontPage from 'routes/frontpage'
import Invite from 'routes/invite'
import InviteLink from 'routes/invitelink'
import Footer from 'routes/components/footer'
import Header from 'routes/components/header'
import NotFound from 'routes/components/notFound'
......@@ -44,15 +45,17 @@ export default function App() {
<AppWrapper>
<Header />
<Switch>
<Route exact path='/'>
<Route exact path="/">
<FrontPage />
</Route>
<ProtectedRoute path='/sponsor'>
<ProtectedRoute path="/sponsor">
<Sponsor />
</ProtectedRoute>
<Route path='/register'>
<Route path="/register">
<Register />
</Route>
<Route path="/invite/:id" component={InviteLink} />
<Route path="/invite/" component={Invite} />
<Route>
<NotFound />
</Route>
......
import Page from 'components/page'
import { useUserContext } from 'contexts'
function Invite() {
const { user } = useUserContext()
return (
<Page>
<p>
{user.first_name} {user.last_name}
TODO: Put information about login options, and buttons to them on this
page
</p>
</Page>
)
}
export default Invite
import { useEffect } from 'react'
import { Redirect, RouteComponentProps } from 'react-router-dom'
type TParams = { id: string }
function InviteLink({ match }: RouteComponentProps<TParams>) {
// Fetch backend endpoint to preserve invite_id in backend session then redirect
// to generic invite page with info about feide login or manual with passport.
const inviteId = match.params.id
useEffect(() => {
fetch(`/api/ui/v1/invited/${inviteId}`)
}, [])
return <Redirect to="/invite" />
}
export default InviteLink
......@@ -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
......
......@@ -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)
......@@ -81,7 +81,6 @@ class PersonSerializer(serializers.ModelSerializer):
"mobile_phone",
"mobile_phone_verified_date",
"registration_completed_date",
"token",
"identities",
"roles",
"consents",
......
# 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),
),
]
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)
......@@ -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
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()
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",
]
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()),
]
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)
......@@ -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)
......@@ -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()),
]
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]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment