From 3f9cd6bfad9d46e83ad1b370732d80793b5e2872 Mon Sep 17 00:00:00 2001
From: Tore Brede <Tore.Brede@uib.no>
Date: Mon, 13 Dec 2021 20:11:08 +0100
Subject: [PATCH] GREG-154: Adding gender field

---
 frontend/public/locales/en/common.json        |  5 +-
 frontend/public/locales/nb/common.json        |  5 +-
 frontend/public/locales/nn/common.json        |  7 +-
 frontend/src/contexts/featureContext.ts       |  3 +
 frontend/src/providers/featureProvider.tsx    | 12 +++-
 .../routes/guest/register/enteredGuestData.ts |  1 +
 .../routes/guest/register/guestDataForm.ts    |  1 +
 frontend/src/routes/guest/register/index.tsx  | 12 +++-
 .../routes/guest/register/steps/register.tsx  | 47 ++++++++++++-
 gregui/api/serializers/guest.py               | 14 ++++
 gregui/api/views/invitation.py                |  4 +-
 gregui/tests/api/views/test_invitation.py     | 69 +++++++++++++++++++
 12 files changed, 170 insertions(+), 10 deletions(-)

diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json
index 61140646..90cf89f5 100644
--- a/frontend/public/locales/en/common.json
+++ b/frontend/public/locales/en/common.json
@@ -23,7 +23,10 @@
     "passportNumber": "Passport number",
     "passportNationality": "Passport nationality",
     "countryCallingCode": "Country code",
-    "contactPersonUnit": "Contact at unit"
+    "contactPersonUnit": "Contact at unit",
+    "gender": "Gender",
+    "male": "Male",
+    "female": "Female"
   },
   "sponsor": {
     "addRole": "Add role",
diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json
index b9f2d0a7..459ee2b9 100644
--- a/frontend/public/locales/nb/common.json
+++ b/frontend/public/locales/nb/common.json
@@ -23,7 +23,10 @@
     "passportNumber": "Passnummer",
     "passportNationality": "Passnasjonalitet",
     "countryCallingCode": "Landkode",
-    "contactPersonUnit": "Kontakt ved avdeling"
+    "contactPersonUnit": "Kontakt ved avdeling",
+    "gender": "Kjønn",
+    "male": "Mann",
+    "female": "Kvinne"
   },
   "sponsor": {
     "addRole": "Legg til rolle",
diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json
index 60fc0cc0..7e6fb203 100644
--- a/frontend/public/locales/nn/common.json
+++ b/frontend/public/locales/nn/common.json
@@ -24,10 +24,13 @@
     "passportNumber": "Passnummer",
     "passportNationality": "Passnasjonalitet",
     "countryCallingCode": "Landkode",
-    "contactPersonUnit": "Kontakt ved avdeling"
+    "contactPersonUnit": "Kontakt ved avdeling",
+    "gender": "Kjønn",
+    "male": "Mann",
+    "female": "Kvinne"
   },
   "sponsor": {
-    "addRole": "Legg til role",
+    "addRole": "Legg til rolle",
     "roleInfoText": "Her kan du endre på start- og sluttdato for gjesterollen eller avslutte perioden",
     "choose": "Velg",
     "details": "Detaljer",
diff --git a/frontend/src/contexts/featureContext.ts b/frontend/src/contexts/featureContext.ts
index 0fd76a4e..42130084 100644
--- a/frontend/src/contexts/featureContext.ts
+++ b/frontend/src/contexts/featureContext.ts
@@ -5,11 +5,14 @@ export interface IFeatureContext {
   displayContactAtUnit: boolean
   // Controls whether the optional field is shown in the register new guest wizard
   displayComment: boolean
+  // Controls whether the gender field is shown for guests
+  showGenderFieldForGuest: boolean
 }
 
 export const FeatureContext = createContext<IFeatureContext>({
   displayContactAtUnit: true,
   displayComment: true,
+  showGenderFieldForGuest: true,
 })
 
 export const useFeatureContext = () => useContext(FeatureContext)
diff --git a/frontend/src/providers/featureProvider.tsx b/frontend/src/providers/featureProvider.tsx
index 962e46aa..7ac6ee48 100644
--- a/frontend/src/providers/featureProvider.tsx
+++ b/frontend/src/providers/featureProvider.tsx
@@ -13,12 +13,20 @@ function FeatureProvider(props: FeatureProviderProps) {
   let features: IFeatureContext
   switch (appInst) {
     case 'uib':
-      features = { displayContactAtUnit: false, displayComment: false }
+      features = {
+        displayContactAtUnit: false,
+        displayComment: false,
+        showGenderFieldForGuest: true,
+      }
       break
 
     case 'uio':
     default:
-      features = { displayContactAtUnit: true, displayComment: true }
+      features = {
+        displayContactAtUnit: true,
+        displayComment: true,
+        showGenderFieldForGuest: false,
+      }
       break
   }
 
diff --git a/frontend/src/routes/guest/register/enteredGuestData.ts b/frontend/src/routes/guest/register/enteredGuestData.ts
index b9bb8e60..e18db96e 100644
--- a/frontend/src/routes/guest/register/enteredGuestData.ts
+++ b/frontend/src/routes/guest/register/enteredGuestData.ts
@@ -12,6 +12,7 @@ export type GuestRegisterData = {
   passportNumber: string
   passportNationality: string
   dateOfBirth: Date | null
+  gender: string | null
 }
 
 export type GuestConsentData = {
diff --git a/frontend/src/routes/guest/register/guestDataForm.ts b/frontend/src/routes/guest/register/guestDataForm.ts
index c1d35dcd..37b96c4f 100644
--- a/frontend/src/routes/guest/register/guestDataForm.ts
+++ b/frontend/src/routes/guest/register/guestDataForm.ts
@@ -15,6 +15,7 @@ export type GuestInviteInformation = {
   role_start: string
   role_end: string
   comment?: string
+  gender?: string
 
   feide_id?: string
   email?: string
diff --git a/frontend/src/routes/guest/register/index.tsx b/frontend/src/routes/guest/register/index.tsx
index c595bac7..7b148dc2 100644
--- a/frontend/src/routes/guest/register/index.tsx
+++ b/frontend/src/routes/guest/register/index.tsx
@@ -1,4 +1,4 @@
-import React, { Suspense, useEffect, useRef, useState } from 'react'
+import React, { Suspense, useContext, useEffect, useRef, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useHistory } from 'react-router-dom'
 import {
@@ -21,6 +21,7 @@ import AuthenticationMethod from './authenticationMethod'
 import GuestRegisterStep from './steps/register'
 import GuestConsentStep from './steps/consent'
 import GuestSuccessStep from './steps/success'
+import { FeatureContext } from '../../../contexts'
 
 enum SubmitState {
   NotSubmitted,
@@ -44,6 +45,7 @@ type InvitationData = {
     passport?: string
     feide_id?: string
     date_of_birth?: string
+    gender?: string
   }
   sponsor: {
     first_name: string
@@ -69,6 +71,7 @@ type InvitationData = {
 export default function GuestRegister() {
   const { t } = useTranslation(['common'])
   const history = useHistory()
+  const { showGenderFieldForGuest } = useContext(FeatureContext)
 
   const guestRegisterRef = useRef<GuestRegisterCallableMethods>(null)
   const guestConsentRef = useRef<GuestRegisterCallableMethods>(null)
@@ -133,6 +136,7 @@ export default function GuestRegister() {
       first_name: data.person.first_name ?? '',
       last_name: data.person.last_name ?? '',
       date_of_birth: dateOfBirth,
+      gender: data.person.gender ?? '',
       email: data.person.email ?? '',
       feide_id: data.person.feide_id ?? '',
       fnr: data.person.fnr ?? '',
@@ -173,6 +177,7 @@ export default function GuestRegister() {
       nationalIdNumber: initialGuestData.fnr ?? '',
       passportNumber: initialGuestData.passport ?? '',
       passportNationality: initialGuestData.passportNationality ?? '',
+      gender: initialGuestData.gender ?? '',
     })
   }, [initialGuestData])
 
@@ -234,6 +239,11 @@ export default function GuestRegister() {
       payload.person.consents = consentData.consents
     }
 
+    // Do not expect gender to be set if the field should not be shown
+    if (showGenderFieldForGuest && registerData.gender) {
+      payload.person.gender = registerData.gender
+    }
+
     return payload
   }
 
diff --git a/frontend/src/routes/guest/register/steps/register.tsx b/frontend/src/routes/guest/register/steps/register.tsx
index 9cc70c71..13d15a31 100644
--- a/frontend/src/routes/guest/register/steps/register.tsx
+++ b/frontend/src/routes/guest/register/steps/register.tsx
@@ -11,6 +11,7 @@ import { SubmitHandler, Controller, useForm } from 'react-hook-form'
 import React, {
   forwardRef,
   Ref,
+  useContext,
   useEffect,
   useImperativeHandle,
   useState,
@@ -30,9 +31,11 @@ import { GuestInviteInformation } from '../guestDataForm'
 import { GuestRegisterData } from '../enteredGuestData'
 import { GuestRegisterCallableMethods } from '../guestRegisterCallableMethods'
 import AuthenticationMethod from '../authenticationMethod'
+import { FeatureContext } from '../../../../contexts'
 
 interface GuestRegisterProperties {
   nextHandler(registerData: GuestRegisterData): void
+
   initialGuestData: GuestInviteInformation
   registerData: GuestRegisterData | null
 }
@@ -46,6 +49,7 @@ const GuestRegisterStep = forwardRef(
   (props: GuestRegisterProperties, ref: Ref<GuestRegisterCallableMethods>) => {
     const { i18n, t } = useTranslation(['common'])
     const { nextHandler, initialGuestData, registerData } = props
+    const { showGenderFieldForGuest } = useContext(FeatureContext)
 
     // For select-components it seems to be easier to tie them to a state
     // and then handle the updating of the form using this, than to tie the
@@ -56,6 +60,7 @@ const GuestRegisterStep = forwardRef(
     const [passportNationality, setPassportNationality] = useState<
       string | undefined
     >(undefined)
+    const [gender, setGender] = useState<string | undefined>(undefined)
     const [idErrorState, setIdErrorState] = useState<string>('')
 
     console.log('register step registerData', registerData)
@@ -151,6 +156,16 @@ const GuestRegisterStep = forwardRef(
       setValue('mobilePhone', value.target.value)
     }
 
+    const handleGenderChange = (event: SelectChangeEvent) => {
+      if (event.target.value) {
+        setGender(event.target.value)
+        setValue('gender', event.target.value)
+      } else {
+        setGender(undefined)
+        setValue('gender', null)
+      }
+    }
+
     const today = new Date()
     const minBirthDate = subYears(100)(today)
     const maxBirthDate = subYears(1)(today)
@@ -186,7 +201,7 @@ const GuestRegisterStep = forwardRef(
         <Box sx={{ maxWidth: '30rem' }}>
           <form onSubmit={onSubmit}>
             <Stack spacing={2}>
-              {/* The name is only editable if it is it is not coming from some trusted source */}
+              {/* The name is only editable if it is not coming from some trusted source */}
               {initialGuestData.authentication_method !==
               AuthenticationMethod.Invite ? (
                 <>
@@ -248,6 +263,34 @@ const GuestRegisterStep = forwardRef(
                 </>
               )}
 
+              {showGenderFieldForGuest && (
+                <Select
+                  sx={{
+                    maxHeight: '2.5rem',
+                    minWidth: '5rem',
+                    marginRight: '0.5rem',
+                  }}
+                  labelId="gender-select"
+                  id="gender-select-id"
+                  displayEmpty
+                  onChange={handleGenderChange}
+                  value={gender}
+                  renderValue={(selected: any) => {
+                    if (!selected) {
+                      return t('input.gender')
+                    }
+                    return t(`input.${selected}`)
+                  }}
+                >
+                  {/*Keep it simple and hardcode the gender values*/}
+                  <MenuItem disabled value="">
+                    {t('input.gender')}
+                  </MenuItem>
+                  <MenuItem value="male">{t('input.male')}</MenuItem>
+                  <MenuItem value="female">{t('input.female')}</MenuItem>
+                </Select>
+              )}
+
               <Controller
                 name="dateOfBirth"
                 control={control}
@@ -349,7 +392,7 @@ const GuestRegisterStep = forwardRef(
                       return countryTuple
                     })
                     .filter(
-                      // A few country codes do no have a country name. Assuming
+                      // A few country codes do not have a country name. Assuming
                       // these are not needed and filtering them out to make the
                       // list look nicer
                       (countryTuple: [CountryCode, string]) =>
diff --git a/gregui/api/serializers/guest.py b/gregui/api/serializers/guest.py
index 471f459d..5198622d 100644
--- a/gregui/api/serializers/guest.py
+++ b/gregui/api/serializers/guest.py
@@ -10,6 +10,7 @@ from gregui.validation import (
     validate_norwegian_national_id_number,
 )
 
+
 # pylint: disable=W0223
 class GuestConsentChoiceSerializer(serializers.Serializer):
     type = serializers.CharField(required=True)
@@ -39,6 +40,7 @@ class GuestRegisterSerializer(serializers.ModelSerializer):
     )
     passport = serializers.CharField(required=False)
     date_of_birth = serializers.DateField(required=False)
+    gender = serializers.CharField(required=False)
     consents = GuestConsentChoiceSerializer(required=False, many=True, write_only=True)
 
     def update(self, instance, validated_data):
@@ -76,6 +78,9 @@ class GuestRegisterSerializer(serializers.ModelSerializer):
         if "date_of_birth" in validated_data:
             instance.date_of_birth = validated_data["date_of_birth"]
 
+        if "gender" in validated_data:
+            instance.gender = validated_data["gender"]
+
         consents = validated_data.get("consents", {})
         self._handle_consents(person=instance, consents=consents)
 
@@ -118,12 +123,21 @@ class GuestRegisterSerializer(serializers.ModelSerializer):
 
         return date_of_birth
 
+    def validate_gender(self, gender):
+        # Looks like the gender choices are enforced by the person model on
+        # serialization, so need to check that the gender is valid here
+        if gender not in Person.GenderType:
+            raise serializers.ValidationError("Unexpected gender value")
+
+        return gender
+
     class Meta:
         model = Person
         fields = (
             "id",
             "first_name",
             "last_name",
+            "gender",
             "email",
             "mobile_phone",
             "fnr",
diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py
index 34b6d4fa..6ac668fe 100644
--- a/gregui/api/views/invitation.py
+++ b/gregui/api/views/invitation.py
@@ -181,8 +181,9 @@ class InvitedGuestView(GenericAPIView):
         "passport",
         "date_of_birth",
         "consents",
+        "gender",
     ]
-    fields_allowed_to_update_if_feide = ["mobile_phone", "consents"]
+    fields_allowed_to_update_if_feide = ["mobile_phone", "consents", "gender"]
 
     def get(self, request, *args, **kwargs):
         """
@@ -223,6 +224,7 @@ class InvitedGuestView(GenericAPIView):
                 "fnr": person.fnr and person.fnr.value,
                 "passport": person.passport and person.passport.value,
                 "feide_id": person.feide_id and person.feide_id.value,
+                "gender": person.gender,
             },
             "sponsor": {
                 "first_name": sponsor.first_name,
diff --git a/gregui/tests/api/views/test_invitation.py b/gregui/tests/api/views/test_invitation.py
index 0fb8e456..4ea6868e 100644
--- a/gregui/tests/api/views/test_invitation.py
+++ b/gregui/tests/api/views/test_invitation.py
@@ -524,3 +524,72 @@ def test_post_invited_info_valid_dnumber(client, invited_person):
     assert response.status_code == status.HTTP_200_OK, response.data
     person.refresh_from_db()
     assert person.fnr.value == d_number
+
+
+@pytest.mark.django_db
+def test_gender_stored(client, invited_person_verified_nin):
+    _, invitation_link = invited_person_verified_nin
+
+    session = client.session
+    session["invite_id"] = str(invitation_link.uuid)
+    session.save()
+
+    date_of_birth = "1995-10-28"
+    url = reverse("gregui-v1:invited-info")
+    data = {
+        "person": {
+            "mobile_phone": "+4797543992",
+            "date_of_birth": date_of_birth,
+            "gender": "male",
+        }
+    }
+    response = client.post(url, data, format="json")
+
+    assert response.status_code == status.HTTP_200_OK
+
+    person = Person.objects.get()
+    assert person.gender == "male"
+
+
+@pytest.mark.django_db
+def test_gender_blank_allowed(client, invited_person_verified_nin):
+    _, invitation_link = invited_person_verified_nin
+
+    session = client.session
+    session["invite_id"] = str(invitation_link.uuid)
+    session.save()
+
+    date_of_birth = "1995-10-28"
+    url = reverse("gregui-v1:invited-info")
+    data = {"person": {"mobile_phone": "+4797543992", "date_of_birth": date_of_birth}}
+    response = client.post(url, data, format="json")
+
+    assert response.status_code == status.HTTP_200_OK
+
+    person = Person.objects.get()
+    assert person.gender is None
+
+
+@pytest.mark.django_db
+def test_invalid_gender_rejected(client, invited_person_verified_nin):
+    _, invitation_link = invited_person_verified_nin
+
+    session = client.session
+    session["invite_id"] = str(invitation_link.uuid)
+    session.save()
+
+    date_of_birth = "1995-10-28"
+    url = reverse("gregui-v1:invited-info")
+    data = {
+        "person": {
+            "mobile_phone": "+4797543992",
+            "date_of_birth": date_of_birth,
+            "gender": "abcdefg",
+        }
+    }
+    response = client.post(url, data, format="json")
+
+    assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+    person = Person.objects.get()
+    assert person.gender is None
-- 
GitLab