From c6ab0cac82940108f3bfac290e1cfb0aae0efb3f Mon Sep 17 00:00:00 2001
From: hhn <hhn@uio.no>
Date: Thu, 21 Sep 2023 15:09:03 +0200
Subject: [PATCH] Add ability for sponsors to reject a guest's registration

If a guest's suplied identification is considered invalid by a sponsor they now have the ablility to reject them.
When a guest is rejected they will be set to unregistered and they will receive an email with a new registration link where they can correct their mistake.
---
 frontend/public/locales/en/common.json        | 12 +++-
 frontend/public/locales/nb/common.json        | 12 +++-
 frontend/public/locales/nn/common.json        | 12 +++-
 .../src/components/identityLine/index.tsx     | 60 ++++++++++++++--
 frontend/src/interfaces/index.ts              |  4 +-
 .../routes/sponsor/guest/guestInfo/index.tsx  | 11 +++
 .../sponsor/guest/guestRoleInfo/index.tsx     |  1 +
 frontend/src/utils/index.ts                   |  1 +
 greg/models.py                                |  4 +-
 greg/utils.py                                 |  8 ++-
 gregui/api/serializers/role.py                |  7 +-
 gregui/api/urls.py                            |  8 ++-
 gregui/api/views/invitation.py                | 68 ++++++++++++++++++-
 gregui/api/views/person.py                    |  1 +
 gregui/api/views/userinfo.py                  |  3 +
 gregui/mailutils/invite_guest.py              | 15 +++-
 .../0005_alter_emailtemplate_template_key.py  | 27 ++++++++
 gregui/models.py                              |  2 +
 gregui/templates/guest_redo_registration.txt  |  7 ++
 gregui/tests/api/serializers/test_guest.py    |  1 +
 gregui/tests/api/views/test_invite_guest.py   | 37 ++++++++++
 gregui/tests/api/views/test_userinfo.py       |  4 +-
 gregui/tests/conftest.py                      | 59 ++++++++++++++--
 23 files changed, 341 insertions(+), 23 deletions(-)
 create mode 100644 gregui/migrations/0005_alter_emailtemplate_template_key.py
 create mode 100644 gregui/templates/guest_redo_registration.txt

diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json
index 3b2a19db..848dd6ae 100644
--- a/frontend/public/locales/en/common.json
+++ b/frontend/public/locales/en/common.json
@@ -160,7 +160,8 @@
     "next": "Next",
     "save": "Save",
     "cancel": "Cancel",
-    "verify": "Verify identification",
+    "verify": "Verify",
+    "reject": "Reject",
     "backToFrontPage": "Go to front page",
     "cancelInvitation": "Cancel",
     "resendInvitation": "Resend invitation",
@@ -207,6 +208,15 @@
     "cancelInvitation": "Cancel invitation?",
     "cancelInvitationDescription": "Do you want to cancel the invitation?"
   },
+  "rejectionDialog": {
+    "text": {
+      "uio": "Before verifying this identity, please make sure that you have seen ID-papers with a picture matching the supplied value. Approved papers are passport, Norwegian driver's license, and Norwegian national ID card.",
+      "uib": "Are you sure you want to reject this identity?",
+      "default": "Are you sure you want to reject this identity?",
+      "helperText": "If the value does not match what the guest has entered click \"YES\", and they will receive a new email to correct it."
+    },
+    "rejectIdentityTitle": "Invalid value?"
+  },
   "error": {
     "error": "Error",
     "changeRoleFailed": "Failed to change role information",
diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json
index 8e3821d9..52a879c0 100644
--- a/frontend/public/locales/nb/common.json
+++ b/frontend/public/locales/nb/common.json
@@ -160,7 +160,8 @@
     "next": "Neste",
     "save": "Lagre",
     "cancel": "Avbryt",
-    "verify": "Godkjenn identifikasjon",
+    "verify": "Godkjenn",
+    "reject": "Avslå",
     "backToFrontPage": "Tilbake til forsiden",
     "resendInvitation": "Send ny invitasjon",
     "cancelInvitation": "Kanseller",
@@ -207,6 +208,15 @@
     "cancelInvitation": "Kanseller invitasjon?",
     "cancelInvitationDescription": "Vil du kansellere invitasjonen?"
   },
+  "rejectionDialog": {
+    "text": {
+      "uio": "Vennligst sammenlign verdien du godkjenner mot ID-papirer med bilde, før du godkjenner. Godkjente papirer er pass, norsk førerkort og norsk nasjonalt ID-kort.",
+      "uib": "Er du sikker på at du vil avslå denne identiteten?",
+      "default": "Er du sikker på at du vil avslå denne identiteten?",
+      "helperText": "Om verdien ikke stemmer overens med det gjesten har lagt inn klikk \"JA\", og de vil da få en ny epost for å rette opp i dette."
+    },
+    "rejectIdentityTitle": "Ugyldig verdi?"
+  },
   "error": {
     "error": "Feil",
     "changeRoleFailed": "Kunne ikke endre rolleinformasjon.",
diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json
index ee585da5..4a996ae4 100644
--- a/frontend/public/locales/nn/common.json
+++ b/frontend/public/locales/nn/common.json
@@ -160,7 +160,8 @@
     "next": "Neste",
     "save": "Lagre",
     "cancel": "Avbryt",
-    "verify": "Godkjenn identifikasjon",
+    "verify": "Godkjenn",
+    "reject": "Avslå",
     "backToFrontPage": "Tilbake til framsida",
     "resendInvitation": "Send ny invitasjon",
     "cancelInvitation": "Kanseller",
@@ -207,6 +208,15 @@
     "cancelInvitation": "Kanseller invitasjon?",
     "cancelInvitationDescription": "Vil du kansellere invitasjonen?"
   },
+  "rejectionDialog": {
+    "text": {
+      "uio": "Vennligst samanlikn verdien mot ID-papirer med bilde, før du godkjenner. Godkjende papirer er pass, norsk førerkort og norsk nasjonalt ID-kort.",
+      "uib": "Er du sikker på at du vil avslå denne identiteten?",
+      "default": "Er du sikker på at du vil avslå denne identiteten?",
+      "helperText": "Om verdien ikkje stemmer overeins med det gjesten har lagt inn klikk \"JA\", og dei vil då få ein ny e-post for å retta opp i dette."
+    },
+    "rejectIdentityTitle": "Ugyldig verdi?"
+  },
   "error": {
     "error": "Feil",
     "changeRoleFailed": "Kunne ikkje endre rolleinformasjon",
diff --git a/frontend/src/components/identityLine/index.tsx b/frontend/src/components/identityLine/index.tsx
index ddf9d53a..6202d8a1 100644
--- a/frontend/src/components/identityLine/index.tsx
+++ b/frontend/src/components/identityLine/index.tsx
@@ -17,6 +17,7 @@ import {
 interface IdentityLineProps {
   text: string
   identity: Identity | null
+  isSponsorForGuest: boolean
   reloadGuest: () => void
   reloadGuests: () => void
 }
@@ -24,20 +25,28 @@ interface IdentityLineProps {
 const IdentityLine = ({
   text,
   identity,
+  isSponsorForGuest,
   reloadGuest,
   reloadGuests,
 }: IdentityLineProps) => {
-  // Make a line with a confirmation button if the identity has not been verified
+  // Make a line with a confirmation button and
+  // rejection button if the identity has not been verified
   if (identity == null) {
     return <></>
   }
-  const [confirmOpen, setConfirmOpen] = useState(false)
+  const [confirmOpen, setConfirmOpen] = useState<boolean>(false)
+  const [rejectOpen, setRejectOpen] = useState<boolean>(false)
   const [t] = useTranslation('common')
   const verifyIdentity = (id: string) => async () => {
     await fetch(`/api/ui/v1/identity/${id}`, submitJsonOpts('PATCH', {}))
     reloadGuest()
     reloadGuests()
   }
+  const rejectIdentity = (id: string) => async () => {
+    await fetch(`/api/ui/v1/invite/${id}/recreate`, submitJsonOpts('PATCH', {}))
+    reloadGuest()
+    reloadGuests()
+  }
   const [verifiedBoxAnchor, setVerifiedBoxAnchor] =
     React.useState<null | HTMLElement>(null)
   const verifiedBoxOpen = Boolean(verifiedBoxAnchor)
@@ -87,7 +96,7 @@ const IdentityLine = ({
     }
     return <></>
   }
-  const getDialogText = () => {
+  const getConfirmDialogText = () => {
     switch (appInst) {
       case 'uio':
         return (
@@ -103,6 +112,23 @@ const IdentityLine = ({
         return t('confirmationDialog.text.default')
     }
   }
+  const getRejectDialogText = () => {
+    const transBuilder = (baseText: string) => (
+      <Trans
+        i18nKey={`${t(baseText)}<br/><br/>${t(
+          'rejectionDialog.text.helperText'
+        )}`}
+      />
+    )
+    switch (appInst) {
+      case 'uio':
+        return transBuilder('rejectionDialog.text.uio')
+      case 'uib':
+        return transBuilder('rejectionDialog.text.uib')
+      default:
+        return transBuilder('rejectionDialog.text.default')
+    }
+  }
 
   useEffect(() => {
     if (enableIgaCheck && confirmOpen && identityCheckText === null) {
@@ -147,7 +173,33 @@ const IdentityLine = ({
                   onConfirm={verifyIdentity(identity.id)}
                 >
                   <IdentityCheckText />
-                  {getDialogText()}
+                  {getConfirmDialogText()}
+                </ConfirmDialog>
+                <Button
+                  aria-label={t('button.verify')}
+                  sx={{
+                    alignSelf: { xs: 'auto', md: 'flex-end' },
+                    marginLeft: { xs: '0rem', md: '1rem' },
+                    marginTop: { xs: '0.3rem', md: '0rem' },
+                  }}
+                  onClick={() => setRejectOpen(true)}
+                  disabled={
+                    !identity ||
+                    (disableNinVerification &&
+                      identity.type === 'norwegian_national_id_number') ||
+                    !isSponsorForGuest
+                  }
+                >
+                  {t('button.reject')}
+                </Button>
+                <ConfirmDialog
+                  title={t('rejectionDialog.rejectIdentityTitle')}
+                  open={rejectOpen}
+                  setOpen={setRejectOpen}
+                  onConfirm={rejectIdentity(identity.id)}
+                >
+                  <IdentityCheckText />
+                  {getRejectDialogText()}
                 </ConfirmDialog>
               </>
             )}
diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts
index e0fcf7a2..4a41825d 100644
--- a/frontend/src/interfaces/index.ts
+++ b/frontend/src/interfaces/index.ts
@@ -14,7 +14,7 @@ export type Guest = {
   roles: Role[]
 }
 
-type VerifiedChoices = "manual" | "automatic" | ""
+type VerifiedChoices = 'manual' | 'automatic' | ''
 
 export type Identity = {
   id: string
@@ -64,6 +64,7 @@ export type Role = {
   contact_person_unit: string | null
   comments: string | null
   sponsor_name: string
+  sponsor_feide_id: string
   ou_id: number
 }
 
@@ -79,6 +80,7 @@ export type FetchedRole = {
   contact_person_unit: string | null
   comments: string | null
   sponsor_name: string
+  sponsor_feide_id: string
   ou_id: number
 }
 
diff --git a/frontend/src/routes/sponsor/guest/guestInfo/index.tsx b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx
index d2fb3726..c1fe4801 100644
--- a/frontend/src/routes/sponsor/guest/guestInfo/index.tsx
+++ b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx
@@ -29,6 +29,7 @@ import {
   TableHeadCell,
   TableRow,
 } from 'components/table'
+import { useUserContext } from 'contexts'
 import { Guest } from 'interfaces'
 import SponsorInfoButtons from 'routes/components/sponsorInfoButtons'
 import { isValidEmail, isValidMobilePhoneNumber, submitJsonOpts } from 'utils'
@@ -121,6 +122,11 @@ export default function GuestInfo({
     useState<ServerErrorReportData>()
   const [updateOKMsg, setUpdateOKMsg] = useState<string>('')
   const [showErrorMessage, setShowErrorMessage] = useState<Boolean>(false)
+  const { user } = useUserContext()
+  const sponsorFeideId = user.feide_id
+  const isSponsorForGuest = guest.roles
+    .map(({ sponsor_feide_id }) => sponsor_feide_id)
+    .includes(sponsorFeideId)
 
   const {
     control,
@@ -499,18 +505,23 @@ export default function GuestInfo({
               <IdentityLine
                 text={t('input.nationalIdNumber')}
                 identity={guest.fnr}
+                isSponsorForGuest={isSponsorForGuest}
                 reloadGuest={reloadGuest}
                 reloadGuests={reloadGuests}
               />
               <IdentityLine
                 text={t('input.passportNumber')}
                 identity={guest.passport}
+                isSponsorForGuest={isSponsorForGuest}
                 reloadGuest={reloadGuest}
                 reloadGuests={reloadGuests}
               />
             </TableBody>
           </Table>
         </TableContainer>
+        <Button type="submit" color="secondary" disabled={!isValid || !isDirty}>
+          {t('button.save')}
+        </Button>
       </Box>
       <Typography sx={{ marginBottom: '1rem' }} variant="h2">
         {t('guestInfo.roleInfoHead')}
diff --git a/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx b/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx
index 8bfdbde9..7e9ecc1a 100644
--- a/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx
+++ b/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx
@@ -138,6 +138,7 @@ export default function GuestRoleInfo({
     contact_person_unit: null,
     comments: null,
     sponsor_name: '',
+    sponsor_feide_id: '',
     ou_id: -1,
   })
   // Prepare min and max date values
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
index b525f654..27f917dd 100644
--- a/frontend/src/utils/index.ts
+++ b/frontend/src/utils/index.ts
@@ -278,6 +278,7 @@ export function parseRole(role: FetchedRole): Role {
     contact_person_unit: role.contact_person_unit,
     comments: role.comments,
     sponsor_name: role.sponsor_name,
+    sponsor_feide_id: role.sponsor_feide_id,
     ou_id: role.ou_id,
   }
 }
diff --git a/greg/models.py b/greg/models.py
index 70a3af83..75ac0e72 100644
--- a/greg/models.py
+++ b/greg/models.py
@@ -711,8 +711,8 @@ 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.
+    Having the uuid of an InvitationLink should grant access to the view for posting.
+    If the Invitation itself is deleted, all InvitationLinks are also removed.
     """
 
     uuid = models.UUIDField(null=False, default=uuid.uuid4, blank=False)
diff --git a/greg/utils.py b/greg/utils.py
index ad312319..5820e4e0 100644
--- a/greg/utils.py
+++ b/greg/utils.py
@@ -195,13 +195,17 @@ def create_objects_for_invitation(
     return person
 
 
-def queue_mail_to_invitee(person: Person, sponsor: Sponsor):
+def queue_mail_to_invitee(
+    person: Person,
+    sponsor: Sponsor,
+    is_fresh_registration: bool = True,
+):
     for invitationlink in InvitationLink.objects.filter(
         invitation__role__person_id=person.id,
         invitation__role__sponsor_id=sponsor.id,
     ):
         invitemailer = InviteGuest()
-        invitemailer.queue_mail(invitationlink)
+        invitemailer.queue_mail(invitationlink, is_fresh_registration)
 
 
 def role_invitation_date_validator(
diff --git a/gregui/api/serializers/role.py b/gregui/api/serializers/role.py
index c820bfe4..4728b115 100644
--- a/gregui/api/serializers/role.py
+++ b/gregui/api/serializers/role.py
@@ -95,6 +95,7 @@ class ExtendedRoleSerializer(serializers.ModelSerializer):
     ou_en = SerializerMethodField(source="orgunit")
     max_days = SerializerMethodField(source="type")
     sponsor_name = SerializerMethodField(source="sponsor")
+    sponsor_feide_id = SerializerMethodField(source="sponsor")
     ou_id = SerializerMethodField(source="orgunit")
 
     def get_name_nb(self, obj):
@@ -115,6 +116,9 @@ class ExtendedRoleSerializer(serializers.ModelSerializer):
     def get_sponsor_name(self, obj):
         return f"{obj.sponsor.first_name} {obj.sponsor.last_name}"
 
+    def get_sponsor_feide_id(self, obj):
+        return obj.sponsor.feide_id
+
     def get_ou_id(self, obj):
         return obj.orgunit.id
 
@@ -132,6 +136,7 @@ class ExtendedRoleSerializer(serializers.ModelSerializer):
             "contact_person_unit",
             "comments",
             "sponsor_name",
+            "sponsor_feide_id",
             "ou_id",
         ]
-        read_only_fields = ["contact_person_unit", "sponsor_name"]
+        read_only_fields = ["contact_person_unit", "sponsor_name", "sponsor_feide_id"]
diff --git a/gregui/api/urls.py b/gregui/api/urls.py
index 409a7edb..d8916b57 100644
--- a/gregui/api/urls.py
+++ b/gregui/api/urls.py
@@ -8,9 +8,10 @@ from gregui.api.views.identity import (
 
 from gregui.api.views.invitation import (
     CheckInvitationView,
-    ResendInvitationView,
     InvitationView,
     InvitedGuestView,
+    RecreateInvitationView,
+    ResendInvitationView,
 )
 from gregui.api.views.ou import OusViewSet
 from gregui.api.views.person import (
@@ -44,6 +45,11 @@ urlpatterns += [
         ResendInvitationView.as_view(),
         name="invite-resend",
     ),
+    path(
+        "invite/<int:identity_id>/recreate",
+        RecreateInvitationView.as_view(),
+        name="invite-recreate",
+    ),
     path("invite/", InvitationView.as_view(), name="invitation"),
     path(
         "person/search/",
diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py
index 6f540b95..9bc7e9a2 100644
--- a/gregui/api/views/invitation.py
+++ b/gregui/api/views/invitation.py
@@ -1,3 +1,4 @@
+import datetime
 from enum import Enum
 from typing import Optional, List
 import structlog
@@ -18,7 +19,7 @@ from rest_framework.response import Response
 from rest_framework.throttling import AnonRateThrottle
 from rest_framework.views import APIView
 
-from greg.models import Identity, InvitationLink, Person
+from greg.models import Consent, Identity, Invitation, InvitationLink, Person
 from greg.permissions import IsSponsor
 from greg.utils import (
     get_default_invitation_expire_date_from_now,
@@ -499,3 +500,68 @@ class ResendInvitationView(UpdateModelMixin, APIView):
                 invitemailer.queue_mail(invitation_link)
 
         return Response(status=status.HTTP_200_OK)
+
+
+class RecreateInvitationView(UpdateModelMixin, APIView):
+    """
+    Endpoint for rejecting a guest's identity data.
+
+    This endpoint is to be used when a sponsor deems a guest's supplied identification data as invalid.
+    The guest will be set to unregistered, and they will receive an email with a new registration link.
+    """
+
+    authentication_classes = [BasicAuthentication, SessionAuthentication]
+    permission_classes = [IsSponsor]
+
+    def patch(self, request, *args, **kwargs) -> Response:
+        sponsor_user = GregUserProfile.objects.get(user=request.user)
+        sponsor = sponsor_user.sponsor
+        if sponsor is None:
+            return Response(status=status.HTTP_403_FORBIDDEN)
+
+        identity_id = kwargs["identity_id"]
+        try:
+            ident: Identity = Identity.objects.get(id=identity_id)
+        except Identity.DoesNotExist:
+            return Response(status=status.HTTP_404_NOT_FOUND)
+        guest: Person = ident.person
+
+        # This endpoint should only be used on guests that has been
+        # registered with invalid data, they should therefore not be verified.
+        if not guest.is_registered or guest.is_verified:
+            return Response(status=status.HTTP_400_BAD_REQUEST)
+
+        with transaction.atomic():
+            # Set the guest as unregistered
+            guest.registration_completed_date = None
+            guest.save()
+
+            # Delete the guest's private mobile to not get
+            # duplicate identity error on reregister
+            private_mobile = Identity.objects.filter(
+                type=Identity.IdentityType.PRIVATE_MOBILE_NUMBER, person__id=guest.id
+            ).first()
+            if private_mobile is not None:
+                private_mobile.delete()
+
+            # Delete the guest's consents to
+            # not violate uniqueness constraint
+            guest_consents = Consent.objects.filter(person__id=guest.id)
+            for consent in guest_consents:
+                consent.delete()
+
+            for invitation in Invitation.objects.filter(
+                role__person_id=guest.id, role__sponsor_id=sponsor.id
+            ):
+                InvitationLink.objects.create(
+                    invitation=invitation,
+                    expire=timezone.now() + datetime.timedelta(days=30),
+                )
+
+        queue_mail_to_invitee(
+            person=guest,
+            sponsor=sponsor,
+            is_fresh_registration=False,
+        )
+
+        return Response(status=status.HTTP_200_OK)
diff --git a/gregui/api/views/person.py b/gregui/api/views/person.py
index 5fd62ae6..92a78f30 100644
--- a/gregui/api/views/person.py
+++ b/gregui/api/views/person.py
@@ -242,6 +242,7 @@ class GuestInfoViewSet(mixins.ListModelMixin, GenericViewSet):
         """
 
         user = GregUserProfile.objects.get(user=self.request.user)
+        assert user.sponsor is not None
         units = user.sponsor.get_allowed_units()
         return (
             Person.objects.filter(roles__orgunit__in=list(units))
diff --git a/gregui/api/views/userinfo.py b/gregui/api/views/userinfo.py
index 385a1709..b20f002f 100644
--- a/gregui/api/views/userinfo.py
+++ b/gregui/api/views/userinfo.py
@@ -79,6 +79,9 @@ class UserInfoView(APIView):
 
         if sponsor:
             content["sponsor_id"] = sponsor.id
+            content["first_name"] = sponsor.first_name
+            content["last_name"] = sponsor.last_name
+            content["feide_id"] = sponsor.feide_id
 
         if person:
             person_roles = [
diff --git a/gregui/mailutils/invite_guest.py b/gregui/mailutils/invite_guest.py
index c8796420..e30d49ca 100644
--- a/gregui/mailutils/invite_guest.py
+++ b/gregui/mailutils/invite_guest.py
@@ -25,6 +25,11 @@ class InviteGuest:
             template_key=EmailTemplate.EmailType.GUEST_REGISTRATION
         )
 
+    def get_redo_registration_template(self):
+        return EmailTemplate.objects.get(
+            template_key=EmailTemplate.EmailType.GUEST_REDO_REGISTRATION
+        )
+
     def get_notice_template(self):
         return EmailTemplate.objects.get(
             template_key=EmailTemplate.EmailType.INVALID_EMAIL
@@ -47,7 +52,11 @@ class InviteGuest:
             recipient_list=[sponsor_mail],
         )
 
-    def queue_mail(self, link: InvitationLink) -> Optional[str]:
+    def queue_mail(
+        self,
+        link: InvitationLink,
+        is_fresh_registration: bool = True,
+    ) -> Optional[str]:
         guest = link.invitation.role.person
         email_address = guest.private_email
         if not email_address:
@@ -64,6 +73,7 @@ class InviteGuest:
                 "sponsor_id": sponsor.id,
                 "sponsor_name": f"{sponsor.first_name} {sponsor.last_name}",
                 "token": link.uuid,
+                "is_fresh_registration": is_fresh_registration,
             },
         )
 
@@ -74,9 +84,12 @@ def try_send_registration_mail(
     sponsor_id: int,
     sponsor_name: str,
     token: str,
+    is_fresh_registration: bool = True,
 ):
     ig = InviteGuest()
     template = ig.get_template()
+    if not is_fresh_registration:
+        template = ig.get_redo_registration_template()
     context = Context(
         {
             "institution": settings.INSTANCE_NAME,
diff --git a/gregui/migrations/0005_alter_emailtemplate_template_key.py b/gregui/migrations/0005_alter_emailtemplate_template_key.py
new file mode 100644
index 00000000..b57fc48c
--- /dev/null
+++ b/gregui/migrations/0005_alter_emailtemplate_template_key.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.1.4 on 2023-07-19 14:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("gregui", "0004_add_email_template_type"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="emailtemplate",
+            name="template_key",
+            field=models.CharField(
+                choices=[
+                    ("guest_registration", "Guest Registration"),
+                    ("guest_redo_registration", "Guest Redo Registration"),
+                    ("sponsor_confirmation", "Sponsor Confirmation"),
+                    ("role_end_reminder", "Role End Reminder"),
+                    ("invalid_email", "Invalid Email"),
+                ],
+                max_length=64,
+                unique=True,
+            ),
+        ),
+    ]
diff --git a/gregui/models.py b/gregui/models.py
index 256f7ba6..201f8710 100644
--- a/gregui/models.py
+++ b/gregui/models.py
@@ -35,6 +35,7 @@ class EmailTemplate(BaseModel):
     EmailType.
 
     GUEST_REGISTRATION is for informing a guest that they have been invited
+    GUEST_REDO_REGISTRATION is for informing a guest that the identification data is incorrect and need to be resubmitted
     SPONSOR_CONFIRMATION is for informing the sponsor they must verify the guest's information
     ROLE_END_REMINDER is used when reminding the sponsor if their ending roles in the near future
     INVALID_EMAIL is used to notify sponsors when email sending fails
@@ -45,6 +46,7 @@ class EmailTemplate(BaseModel):
         """Types of Emails"""
 
         GUEST_REGISTRATION = "guest_registration"
+        GUEST_REDO_REGISTRATION = "guest_redo_registration"
         SPONSOR_CONFIRMATION = "sponsor_confirmation"
         ROLE_END_REMINDER = "role_end_reminder"
         INVALID_EMAIL = "invalid_email"
diff --git a/gregui/templates/guest_redo_registration.txt b/gregui/templates/guest_redo_registration.txt
new file mode 100644
index 00000000..9f6ad37c
--- /dev/null
+++ b/gregui/templates/guest_redo_registration.txt
@@ -0,0 +1,7 @@
+Dette er en automatisk generert melding fra gjesteregistreringstjenesten.
+Din registrering ved {{ institution }} har blitt avslått av {{ sponsor }} grunnet ugyldig identifisering.
+For å rette opp i dette, vennligst følg denne lenken: {{ registration_link }}
+
+This message has been automatically generated by the guest registration system.
+Your registration at {{ institution }} has been rejected by {{ sponsor }} due to invalid identification.
+To amend this, please follow this link: {{ registration_link }}
diff --git a/gregui/tests/api/serializers/test_guest.py b/gregui/tests/api/serializers/test_guest.py
index 6798cc40..6763df8d 100644
--- a/gregui/tests/api/serializers/test_guest.py
+++ b/gregui/tests/api/serializers/test_guest.py
@@ -35,6 +35,7 @@ def test_serialize_guest(invited_person):
                 "contact_person_unit": "",
                 "comments": "",
                 "sponsor_name": "Sponsor Bar",
+                "sponsor_feide_id": "foo@example.org",
                 "ou_id": 1,
             },
         ],
diff --git a/gregui/tests/api/views/test_invite_guest.py b/gregui/tests/api/views/test_invite_guest.py
index 06d28720..e8e188da 100644
--- a/gregui/tests/api/views/test_invite_guest.py
+++ b/gregui/tests/api/views/test_invite_guest.py
@@ -285,3 +285,40 @@ def test_invite_resend_person_multiple_expired_links_send_all(
     response = client.patch(url)
     assert response.status_code == status.HTTP_200_OK
     assert send_invite_mock_function.call_count == 2
+
+
+@pytest.mark.django_db
+def test_invite_recreate(
+    client,
+    log_in,
+    user_sponsor,
+    person_registered: Person,
+    mocker,
+):
+    send_invite_mock_function = mocker.patch.object(InviteGuest, "queue_mail")
+    log_in(user_sponsor)
+
+    assert person_registered.is_registered is True
+    assert person_registered.is_verified is False
+    assert person_registered.private_mobile is not None
+    invitation_links = InvitationLink.objects.filter(
+        invitation__role__person_id=person_registered.id
+    )
+    assert invitation_links.count() == 0
+
+    identity_id = person_registered.feide_id.id
+    url = reverse("gregui-v1:invite-recreate", kwargs={"identity_id": identity_id})
+    response = client.patch(url)
+    person_registered = Person.objects.get(id=person_registered.id)
+
+    assert person_registered.is_registered is False
+    assert person_registered.is_verified is False
+    assert person_registered.private_mobile is None
+    invitation_links = InvitationLink.objects.filter(
+        invitation__role__person_id=person_registered.id
+    )
+    # person_registered had two invites from this sponsor, so two links should have been created
+    assert invitation_links.count() == 2
+    # person_registered had two invites from this sponsor, so two emails should have been sent
+    assert send_invite_mock_function.call_count == 2
+    assert response.status_code == status.HTTP_200_OK
diff --git a/gregui/tests/api/views/test_userinfo.py b/gregui/tests/api/views/test_userinfo.py
index e6212bc7..964c4ef8 100644
--- a/gregui/tests/api/views/test_userinfo.py
+++ b/gregui/tests/api/views/test_userinfo.py
@@ -63,7 +63,9 @@ def test_userinfo_sponsor_get(client, log_in, user_sponsor):
     assert response.status_code == status.HTTP_200_OK
     assert response.json() == {
         "auth_type": "oidc",
-        "feide_id": "",
+        "first_name": "Sponsor",
+        "last_name": "Bar",
+        "feide_id": "foo@example.org",
         "person_id": None,
         "roles": [],
         "consents": [],
diff --git a/gregui/tests/conftest.py b/gregui/tests/conftest.py
index d4b78773..f36419c9 100644
--- a/gregui/tests/conftest.py
+++ b/gregui/tests/conftest.py
@@ -382,14 +382,18 @@ def create_person() -> (
     def create_person(
         first_name: str,
         last_name: str,
-        email: str = None,
-        phone_number: str = None,
-        nin: str = None,
-        feide_id: str = None,
-        date_of_birth: datetime.date = None,
+        email: Optional[str] = None,
+        phone_number: Optional[str] = None,
+        nin: Optional[str] = None,
+        feide_id: Optional[str] = None,
+        date_of_birth: Optional[datetime.date] = None,
+        registration_completed_date: Optional[datetime.date] = None,
     ) -> Person:
         person = Person.objects.create(
-            first_name=first_name, last_name=last_name, date_of_birth=date_of_birth
+            first_name=first_name,
+            last_name=last_name,
+            date_of_birth=date_of_birth,
+            registration_completed_date=registration_completed_date,
         )
 
         if nin:
@@ -451,6 +455,49 @@ def person_invited(create_person) -> Person:
     return Person.objects.get(id=person.id)
 
 
+@pytest.fixture
+def person_registered(
+    create_person,
+    create_role,
+    create_invitation,
+    sponsor_foo,
+    sponsor_bar,
+    unit_foo,
+    role_type_foo,
+) -> Person:
+    """Invited person after registration."""
+    person = create_person(
+        first_name="Foofoo",
+        last_name="Barbar",
+        email="foofoo@example.org",
+        feide_id="foofoo@example.org",
+        phone_number="41111111",
+        registration_completed_date=datetime.date(2023, 1, 1),
+    )
+    role_1 = create_role(
+        person=person,
+        sponsor=sponsor_foo,
+        unit=unit_foo,
+        role_type=role_type_foo,
+    )
+    role_2 = create_role(
+        person=person,
+        sponsor=sponsor_foo,
+        unit=unit_foo,
+        role_type=role_type_foo,
+    )
+    role_3 = create_role(
+        person=person,
+        sponsor=sponsor_bar,
+        unit=unit_foo,
+        role_type=role_type_foo,
+    )
+    create_invitation(role=role_1)
+    create_invitation(role=role_2)
+    create_invitation(role=role_3)
+    return Person.objects.get(id=person.id)
+
+
 @pytest.fixture
 def invited_person(
     create_role,
-- 
GitLab