diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 7b4fba330475944249c4764d59c5e02a28c7eb9c..8278524650d7ff5e5c92c693124e333941b7a896 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 0b21b5904f29757a45c000cfbcfda6a3a325fba8..3c810fc97ae6762219b14eb9321c12b183304b2a 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 a16fe80431adcfc2fc817c3cf25d3f6eeeb4e3bf..aca3e80a02e9e5dcdd122bbacd709d787e4e03ea 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 238f2bc3a99393edc4e6d7f4a113d4caba9bc6ab..b1cec4f71d62e2d0627a259150defde289a5376f 100644 --- a/frontend/src/contexts/featureContext.ts +++ b/frontend/src/contexts/featureContext.ts @@ -7,12 +7,15 @@ export interface IFeatureContext { displayComment: boolean // Should the contact at unit field be shown for the guest when he registers his information? displayContactAtUnitGuestInput: boolean + // Controls whether the gender field is shown for guests + showGenderFieldForGuest: boolean } export const FeatureContext = createContext<IFeatureContext>({ displayContactAtUnit: true, displayComment: true, displayContactAtUnitGuestInput: true, + showGenderFieldForGuest: true, }) export const useFeatureContext = () => useContext(FeatureContext) diff --git a/frontend/src/providers/featureProvider.tsx b/frontend/src/providers/featureProvider.tsx index 6f5e724225ed67e2ced996aa9c7865d7f9db33a6..771b78d93fd0a2fc0c0eeb73f07bda89b9c359a2 100644 --- a/frontend/src/providers/featureProvider.tsx +++ b/frontend/src/providers/featureProvider.tsx @@ -17,6 +17,7 @@ function FeatureProvider(props: FeatureProviderProps) { displayContactAtUnit: false, displayComment: false, displayContactAtUnitGuestInput: false, + showGenderFieldForGuest: true, } break @@ -26,6 +27,7 @@ function FeatureProvider(props: FeatureProviderProps) { displayContactAtUnit: true, displayComment: true, displayContactAtUnitGuestInput: true, + showGenderFieldForGuest: false, } break } diff --git a/frontend/src/routes/guest/register/enteredGuestData.ts b/frontend/src/routes/guest/register/enteredGuestData.ts index b9bb8e60b427f1c8ef2b6f11696cf570d8278800..e18db96e647d3259deb19081e1e6f113298da250 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 5c86b6000fccd05c06dd091b5198ef23c3a99c6a..4e941f730ab29f54419f2d1bebd7a60bf45b0208 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 contact_person_unit?: 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 d1adc3e554da35240b7ab474384fcf8017291060..7b419965373f37412c91af5020a219615b1940af 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' import ServerErrorReport, { ServerErrorReportData, } from '../../../components/errorReport' @@ -41,6 +42,7 @@ type InvitationData = { passport?: string feide_id?: string date_of_birth?: string + gender?: string } sponsor: { first_name: string @@ -66,6 +68,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) @@ -151,6 +154,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 ?? '', @@ -191,6 +195,7 @@ export default function GuestRegister() { nationalIdNumber: initialGuestData.fnr ?? '', passportNumber: initialGuestData.passport ?? '', passportNationality: initialGuestData.passportNationality ?? '', + gender: initialGuestData.gender ?? '', }) }, [initialGuestData]) @@ -254,6 +259,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 4fc11828581d60d459660021035346a25ba7cef2..ae63cefe43cf51d50f226c48cfbda6efcb5dabd2 100644 --- a/frontend/src/routes/guest/register/steps/register.tsx +++ b/frontend/src/routes/guest/register/steps/register.tsx @@ -50,6 +50,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 @@ -60,6 +61,7 @@ const GuestRegisterStep = forwardRef( const [passportNationality, setPassportNationality] = useState< string | undefined >(undefined) + const [gender, setGender] = useState<string>('') const [idErrorState, setIdErrorState] = useState<string>('') const { displayContactAtUnitGuestInput } = useContext(FeatureContext) @@ -173,6 +175,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('') + setValue('gender', null) + } + } + const today = new Date() const minBirthDate = subYears(100)(today) const maxBirthDate = subYears(1)(today) @@ -228,7 +240,7 @@ const GuestRegisterStep = forwardRef( </Typography> <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 ? ( <> @@ -290,6 +302,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} @@ -391,7 +431,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/greg/migrations/0020_person_gender.py b/greg/migrations/0020_person_gender.py new file mode 100644 index 0000000000000000000000000000000000000000..cf15437b37289a867752c7227a3ff20908f8e11d --- /dev/null +++ b/greg/migrations/0020_person_gender.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.9 on 2021-12-13 13:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("greg", "0019_add_ou_parent_relatedname"), + ] + + operations = [ + migrations.AddField( + model_name="person", + name="gender", + field=models.CharField( + choices=[("male", "Male"), ("female", "Female")], + max_length=6, + null=True, + ), + ), + ] diff --git a/greg/models.py b/greg/models.py index a3eb03a1a66ff1fb8e9b6bb9e3f7ff4fcbb9ceab..e2af784fe6751bbb727def62a9bbf0a132d02889 100644 --- a/greg/models.py +++ b/greg/models.py @@ -39,10 +39,15 @@ class BaseModel(DirtyFieldsMixin, models.Model): class Person(BaseModel): """A person is someone who has requested guest access.""" + class GenderType(models.TextChoices): + MALE = "male" + FEMALE = "female" + first_name = models.CharField(max_length=256) last_name = models.CharField(max_length=256) date_of_birth = models.DateField(null=True) registration_completed_date = models.DateField(null=True) + gender = models.CharField(null=True, max_length=6, choices=GenderType.choices) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, diff --git a/gregui/api/serializers/guest.py b/gregui/api/serializers/guest.py index 471f459de8c20b07ed0860f9ee088ecb5ed2b985..5198622de042cb9d97ea70b89ab3c698c7ec4358 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 54cc778e9dad6c7b00f396dec78eb16f7d0403bd..1df8e74ba20219de72d713bdb8483723cc49fdf1 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): """ @@ -229,6 +230,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 3fe44484be40671ca3ea00f56dcc4b65eff84c37..755cdc3daa5fb6c7e0970dd2354b48b0abb65a08 100644 --- a/gregui/tests/api/views/test_invitation.py +++ b/gregui/tests/api/views/test_invitation.py @@ -93,6 +93,7 @@ def test_get_invited_info_session_okay( passport=None, feide_id=None, date_of_birth=None, + gender=None, ) assert data.get("sponsor") == dict( first_name=sponsor_foo_data["first_name"], @@ -524,3 +525,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