diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index c1f7e439edae23e44ed6a45de1e1c0fae96c4ad9..2f2f2063e5f4c367f2c3bf0bb2bcbfc0aa2c068f 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -89,6 +89,7 @@ "roleTypeRequired": "Role type is required", "roleEndRequired": "Role end date is required", "emailRequired": "E-mail is required", + "consentRequired": "This consent is required", "invalidMobilePhoneNumber": "Invalid phone number", "invalidEmail": "Invalid e-mail address", "passportNumberRequired": "Passport number required", diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index 64ac355125d35d8c40de428d91df23a59ca8e5ce..ec30342da2e1b3dcd488a554c29fe905d8877675 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -88,6 +88,7 @@ "roleTypeRequired": "Rolletype er obligatorisk", "roleEndRequired": "Sluttdato for rolle er obligatorisk", "emailRequired": "E-post er obligatorisk", + "consentRequired": "Dette samtykket er obligatorisk", "invalidMobilePhoneNumber": "Ugyldig telefonnummer", "invalidEmail": "Ugyldig e-postadresse", "passportNumberRequired": "Passnummer er obligatorisk", diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index 6000ca6ee8d12388338fda29ebddcc6129a9ead7..25d2b543b15648b4977b259b73cddbab5d9d8d44 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -89,6 +89,7 @@ "roleTypeRequired": "Rolletype er obligatorisk", "roleEndRequired": "Sluttdato for rolle er obligatorisk", "emailRequired": "E-post er obligatorisk", + "consentRequired": "Dette samtykket er obligatorisk", "invalidMobilePhoneNumber": "Ugyldig telefonnummer", "invalidEmail": "Ugyldig e-postadresse", "passportNumberRequired": "Passnummer er obligatorisk", diff --git a/frontend/src/components/debug/index.tsx b/frontend/src/components/debug/index.tsx index c71af5ea554f17960e0dd5f393603742066e7be9..aca2b8bcbe8b9f8fdccb4a23572584ab6131a899 100644 --- a/frontend/src/components/debug/index.tsx +++ b/frontend/src/components/debug/index.tsx @@ -17,6 +17,7 @@ import { import { appInst, appTimezone, appVersion } from 'appConfig' import { Link } from 'react-router-dom' +import { useUserContext } from 'contexts' const Yes = () => <CheckIcon color="success" /> const No = () => <ClearIcon color="error" /> @@ -26,8 +27,8 @@ export const Debug = () => { const [didContactApi, setDidContactApi] = useState(false) const { i18n } = useTranslation(['common']) const [csrf, setCsrf] = useState<String | null>(null) - const [isAuthenticated, setIsAuthenticated] = useState(false) const [username, setUsername] = useState(undefined) + const { user } = useUserContext() if (!didContactApi) { setDidContactApi(true) @@ -70,15 +71,9 @@ export const Debug = () => { .then((res) => res.json()) .then((data) => { console.log(data) - if (data.isAuthenticated) { - setIsAuthenticated(true) - } else { - setIsAuthenticated(false) - getCSRF() - } + getCSRF() }) .catch((err) => { - setIsAuthenticated(false) console.log(err) }) } @@ -126,7 +121,6 @@ export const Debug = () => { .then(isResponseOk) .then((data) => { console.log(data) - setIsAuthenticated(false) getCSRF() }) .catch((err) => { @@ -142,7 +136,7 @@ export const Debug = () => { ['Theme', appInst], ['Institution', appInst], ['API reachable?', apiHealth === 'yes' ? <Yes /> : apiHealth], - ['Authenticated?', isAuthenticated ? <Yes /> : <No />], + ['Authenticated?', user.auth ? <Yes /> : <No />], ['Username', username], ['CSRF', csrf], ] diff --git a/frontend/src/hooks/useConsentTypes/index.ts b/frontend/src/hooks/useConsentTypes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..597bb716184ac1eb866b8c6fe68be1cec4048ec3 --- /dev/null +++ b/frontend/src/hooks/useConsentTypes/index.ts @@ -0,0 +1,49 @@ +import { useState, useEffect } from 'react' +import { fetchJsonOpts } from 'utils' + +type ConsentChoice = { + id?: number + value: string + text_en: string + text_nb: string + text_nn: string +} + +type ConsentType = { + id: number + identifier: string + name_en: string + name_nb: string + name_nn: string + description_en: string + description_nb: string + description_nn: string + mandatory: boolean + user_allowed_to_change: boolean + valid_from: string + choices: ConsentChoice[] +} + +export function useConsentTypes(): ConsentType[] { + const [consentTypes, setConsentTypes] = useState<ConsentType[]>([]) + + async function fetchConsentTypes() { + fetch(`/api/ui/v1/consenttypes`, fetchJsonOpts()) + .then((data) => data.text()) + .then((result) => { + setConsentTypes(JSON.parse(result)) + }) + .catch((error) => { + console.error(error) + }) + } + + useEffect(() => { + fetchConsentTypes() + }, []) + + return consentTypes +} + +export type { ConsentType, ConsentChoice } +export default useConsentTypes diff --git a/frontend/src/hooks/useOus/index.tsx b/frontend/src/hooks/useOus/index.tsx index b8073e4e14c1263f6f5858712e41b1a72f433458..0109ec3bdf853e2930c2598b5a96155acd89428d 100644 --- a/frontend/src/hooks/useOus/index.tsx +++ b/frontend/src/hooks/useOus/index.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { fetchJsonOpts } from 'utils' type OuData = { id: number @@ -9,7 +10,7 @@ type OuData = { function useOus(): OuData[] { const [ous, setOus] = useState<OuData[]>([]) const getOptions = async () => { - const response = await fetch('/api/ui/v1/ous?format=json') + const response = await fetch('/api/ui/v1/ous/', fetchJsonOpts()) if (response.ok) { const ousJson = await response.json() setOus(ousJson) diff --git a/frontend/src/routes/guest/register/enteredGuestData.ts b/frontend/src/routes/guest/register/enteredGuestData.ts index 45422db11b05ea077d3b062dbb537e852ec64a86..77a914d905ba3177401c0af05c8d89793cd9139e 100644 --- a/frontend/src/routes/guest/register/enteredGuestData.ts +++ b/frontend/src/routes/guest/register/enteredGuestData.ts @@ -3,7 +3,7 @@ * separate from ContactInformationBySponsor to make it clear that * most of the data there the guest cannot change. */ -export type EnteredGuestData = { +export type GuestRegisterData = { firstName: string lastName: string mobilePhoneCountry: string @@ -13,3 +13,10 @@ export type EnteredGuestData = { passportNationality: string dateOfBirth?: Date } + +export type GuestConsentData = { + consents?: Array<{ + type: string + choice: string + }> +} diff --git a/frontend/src/routes/guest/register/index.tsx b/frontend/src/routes/guest/register/index.tsx index 712e30e2d1f229d4c6a177571e3b121e90e23733..e5fe322316c20f0c7a8677a5b5214b727cc1436d 100644 --- a/frontend/src/routes/guest/register/index.tsx +++ b/frontend/src/routes/guest/register/index.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { Suspense, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Box, Button } from '@mui/material' +import { Box, Button, CircularProgress } from '@mui/material' import Page from 'components/page' import { useHistory } from 'react-router-dom' @@ -11,14 +11,15 @@ import { getCountryCallingCode, } from 'libphonenumber-js' import format from 'date-fns/format' +import { splitPhoneNumber, submitJsonOpts, fetchJsonOpts } from 'utils' import OverviewGuestButton from '../../components/overviewGuestButton' -import GuestRegisterStep from './registerPage' import { GuestRegisterCallableMethods } from './guestRegisterCallableMethods' -import { EnteredGuestData } from './enteredGuestData' +import { GuestRegisterData, GuestConsentData } from './enteredGuestData' import { GuestInviteInformation } from './guestDataForm' import AuthenticationMethod from './authenticationMethod' -import { splitPhoneNumber, submitJsonOpts } from '../../../utils' -import StepSubmitSuccessGuest from './submitSuccessPage' +import GuestRegisterStep from './steps/register' +import GuestConsentStep from './steps/consent' +import GuestSuccessStep from './steps/success' enum SubmitState { NotSubmitted, @@ -27,8 +28,37 @@ enum SubmitState { } enum Step { - RegisterStep = 0, - SubmitSuccessStep = 1, + RegisterStep, + ConsentStep, + SuccessStep, +} + +type InvitationData = { + person: { + first_name: string + last_name: string + email?: string + mobile_phone?: string + fnr?: string + passport?: string + feide_id?: string + } + sponsor: { + first_name: string + last_name: string + } + role: { + ou_name_nb: string + ou_name_en: string + role_name_nb: string + role_name_en: string + start: string + end: string + comments: string + } + meta: { + session_type: string + } } /* @@ -44,157 +74,172 @@ export default function GuestRegister() { ) const guestRegisterRef = useRef<GuestRegisterCallableMethods>(null) // TODO Set step when user moves between pages - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [activeStep, setActiveStep] = useState(0) - const [guestFormData, setGuestFormData] = useState<GuestInviteInformation>({ - first_name: '', - last_name: '', - ou_name_en: '', - ou_name_nb: '', - role_name_en: '', - role_name_nb: '', - role_start: '', - role_end: '', - comment: '', - email: '', - mobile_phone: '', - fnr: '', - passport: '', - passportNationality: '', - countryForCallingCode: '', - dateOfBirth: undefined, - authentication_method: AuthenticationMethod.Invite, - }) - - const guestContactInfo = async () => { - try { - const response = await fetch('/api/ui/v1/invited/') - - if (response.ok) { - response.json().then((jsonResponse) => { - const authenticationMethod = - jsonResponse.meta.session_type === 'invite' - ? AuthenticationMethod.Invite - : AuthenticationMethod.Feide - - const [countryCode, nationalNumber] = jsonResponse.person.mobile_phone - ? splitPhoneNumber(jsonResponse.person.mobile_phone) - : ['', ''] - - let extractedCountryCode = '' - if (countryCode) { - const matchingCountries = getCountries().find( - (value) => getCountryCallingCode(value) === countryCode - ) - - if (matchingCountries && matchingCountries.length > 0) { - extractedCountryCode = matchingCountries.toString() - } - } - - let passportNumber = '' - let passportCountry = '' - if (jsonResponse.person.passport) { - const index = jsonResponse.person.passport.indexOf('-') - - if (index !== -1) { - passportCountry = jsonResponse.person.passport.substring(0, index) - passportNumber = jsonResponse.person.passport.substring( - index + 1, - jsonResponse.person.passport.length - ) - } - } - - setGuestFormData({ - fnr: jsonResponse.person.fnr, - passportNationality: passportCountry, - passport: passportNumber, - - first_name: jsonResponse.person.first_name, - last_name: jsonResponse.person.last_name, - ou_name_en: jsonResponse.role.ou_name_en, - ou_name_nb: jsonResponse.role.ou_name_nb, - role_name_en: jsonResponse.role.role_name_en, - role_name_nb: jsonResponse.role.role_name_nb, - role_start: jsonResponse.role.start, - role_end: jsonResponse.role.end, - comment: jsonResponse.role.comments, - - email: jsonResponse.person.email, - feide_id: jsonResponse.person.feide_id, - mobile_phone_country_code: countryCode, - mobile_phone: nationalNumber, - countryForCallingCode: extractedCountryCode, - - dateOfBirth: jsonResponse.person.date_of_birth, - - authentication_method: authenticationMethod, - }) - }) + const [initialGuestData, setInitialGuestData] = + useState<GuestInviteInformation>({ + first_name: '', + last_name: '', + ou_name_en: '', + ou_name_nb: '', + role_name_en: '', + role_name_nb: '', + role_start: '', + role_end: '', + comment: '', + email: '', + mobile_phone: '', + dateOfBirth: undefined, + fnr: '', + passport: '', + passportNationality: '', + countryForCallingCode: '', + authentication_method: AuthenticationMethod.Invite, + }) + + const [guestRegisterData, setGuestRegisterData] = + useState<GuestRegisterData | null>(null) + const [guestConsentData, setGuestConsentData] = + useState<GuestConsentData | null>(null) + + const fetchInvitationData = async () => { + const response = await fetch('/api/ui/v1/invited/', fetchJsonOpts()) + if (!response.ok) { + return + } + const data: InvitationData = await response.json() + + const authenticationMethod = + data.meta.session_type === 'invite' + ? AuthenticationMethod.Invite + : AuthenticationMethod.Feide + + const [countryCode, nationalNumber] = data.person.mobile_phone + ? splitPhoneNumber(data.person.mobile_phone) + : ['', ''] + + let extractedCountryCode = '' + if (countryCode) { + const matchingCountries = getCountries().find( + (value) => getCountryCallingCode(value) === countryCode + ) + + if (matchingCountries && matchingCountries.length > 0) { + extractedCountryCode = matchingCountries.toString() + } + } + + let passportNumber = '' + let passportNationality = '' + if (data.person.passport) { + const parts = data.person.passport.split('-', 1) + if (parts.length === 2) { + ;[passportNationality, passportNumber] = parts } - } catch (error) { - console.log(error) } + + setInitialGuestData({ + first_name: data.person.first_name, + last_name: data.person.last_name, + ou_name_en: data.role.ou_name_en, + ou_name_nb: data.role.ou_name_nb, + role_name_en: data.role.role_name_en, + role_name_nb: data.role.role_name_nb, + role_start: data.role.start, + role_end: data.role.end, + comment: data.role.comments, + + email: data.person.email, + feide_id: data.person.feide_id, + fnr: data.person.fnr, + + passport: passportNumber, + passportNationality, + + mobile_phone_country_code: countryCode, + mobile_phone: nationalNumber, + countryForCallingCode: extractedCountryCode, + + authentication_method: authenticationMethod, + }) } useEffect(() => { - guestContactInfo() + fetchInvitationData() }, []) const handleNext = () => { - // TODO Go to consent page - // setActiveStep((prevActiveStep) => prevActiveStep + 1) - } - - const handleSave = () => { - if (activeStep === 0) { + if (activeStep === Step.RegisterStep) { if (guestRegisterRef.current) { guestRegisterRef.current.doSubmit() } } } - const handleForwardFromRegister = ( - updateFormData: EnteredGuestData - ): void => { - // TODO Should go to consent page here, if there are consents defined in the database. Submit should happen after after consent page + const handleBack = () => { + if (activeStep === Step.ConsentStep) { + setActiveStep(Step.RegisterStep) + } + } - // Only add fields to the objects that the user can change (this is also checked on the server side) - const payload: any = {} - payload.person = {} + const makePayload = ( + registerData: GuestRegisterData, + consentData: GuestConsentData + ): any => { + const payload: any = { + person: {}, + } payload.person.mobile_phone = `+${getCountryCallingCode( - updateFormData.mobilePhoneCountry as CountryCode - )}${updateFormData.mobilePhone}` + registerData.mobilePhoneCountry as CountryCode + )}${registerData.mobilePhone}` - if (guestFormData.authentication_method === AuthenticationMethod.Invite) { + if ( + initialGuestData.authentication_method === AuthenticationMethod.Invite + ) { // The authentication method is Invite, so the name does not come from a // trusted third-party source, and the user can update it - payload.person.first_name = updateFormData.firstName - payload.person.last_name = updateFormData.lastName + payload.person.first_name = registerData.firstName + payload.person.last_name = registerData.lastName + } + + if (registerData.passportNumber && registerData.passportNationality) { + payload.person.passport = `${registerData.passportNationality}-${registerData.passportNumber}` } - if (updateFormData.passportNumber && updateFormData.passportNationality) { - payload.person.passport = `${updateFormData.passportNationality}-${updateFormData.passportNumber}` + if (registerData.nationalIdNumber) { + payload.person.fnr = registerData.nationalIdNumber } - if (updateFormData.nationalIdNumber) { - payload.person.fnr = updateFormData.nationalIdNumber + if (consentData.consents) { + payload.person.consents = consentData.consents } - if (updateFormData.dateOfBirth) { + if (registerData.dateOfBirth) { payload.person.date_of_birth = format( - updateFormData.dateOfBirth as Date, + registerData.dateOfBirth as Date, 'yyyy-MM-dd' ) } + return payload + } + + const submitPayload = () => { + if (!guestRegisterData) { + setActiveStep(Step.RegisterStep) + return + } + if (!guestConsentData) { + setActiveStep(Step.ConsentStep) + return + } + const payload: any = makePayload(guestRegisterData, guestConsentData) + console.log('submitting payload', payload) fetch('/api/ui/v1/invited/', submitJsonOpts('POST', payload)) .then((response) => { if (response.ok) { setSubmitState(SubmitState.Submitted) - setActiveStep(Step.SubmitSuccessStep) + setActiveStep(Step.SuccessStep) } else { setSubmitState(SubmitState.SubmittedError) console.error(`Server responded with status: ${response.status}`) @@ -206,6 +251,27 @@ export default function GuestRegister() { }) } + const handleForwardFromRegister = (registerData: GuestRegisterData): void => { + console.log('handleForwardFromRegister') + setGuestRegisterData(registerData) + setActiveStep(Step.ConsentStep) + } + + const handleForwardFromConsent = (consentData: GuestConsentData): void => { + console.log('handleForwardFromConsent') + setGuestConsentData(consentData) + if (!guestRegisterData) { + setActiveStep(Step.RegisterStep) + return + } + + if (!guestConsentData) { + setActiveStep(Step.ConsentStep) + return + } + submitPayload() + } + const handleCancel = () => { history.push('/') } @@ -213,17 +279,34 @@ export default function GuestRegister() { return ( <Page> <OverviewGuestButton /> - {/* Current page in wizard */} - <Box sx={{ width: '100%' }}> - {activeStep === Step.RegisterStep && ( + {/* Step: Registration */} + {activeStep === Step.RegisterStep && ( + <Box sx={{ width: '100%' }}> <GuestRegisterStep nextHandler={handleForwardFromRegister} - guestData={guestFormData} + initialGuestData={initialGuestData} + registerData={guestRegisterData} ref={guestRegisterRef} /> - )} - </Box> - + </Box> + )} + + {/* Step: Consent */} + {activeStep === Step.ConsentStep && ( + <Box sx={{ width: '100%' }}> + <Suspense fallback={<CircularProgress />}> + <GuestConsentStep + nextHandler={handleForwardFromConsent} + ref={guestRegisterRef} + /> + </Suspense> + </Box> + )} + + {/* Step: Success */} + {activeStep === Step.SuccessStep && <GuestSuccessStep />} + + {/* Navigation */} <Box sx={{ display: 'flex', @@ -243,20 +326,22 @@ export default function GuestRegister() { {t('button.next')} </Button> )} + {activeStep === Step.ConsentStep && ( + <Button + data-testid="button-black" + sx={{ color: 'theme.palette.secondary', mr: 1 }} + onClick={handleBack} + > + {t('button.back')} + </Button> + )} - {activeStep !== Step.SubmitSuccessStep && ( - <> - <Button color="secondary" onClick={handleCancel}> - {t('button.cancel')} - </Button> - - {/* TODO This button is only for testing while developing */} - <Button onClick={handleSave}>{t('button.save')}</Button> - </> + {activeStep !== Step.SuccessStep && ( + <Button color="secondary" onClick={handleCancel}> + {t('button.cancel')} + </Button> )} </Box> - - {activeStep === Step.SubmitSuccessStep && <StepSubmitSuccessGuest />} </Page> ) } diff --git a/frontend/src/routes/guest/register/steps/consent.tsx b/frontend/src/routes/guest/register/steps/consent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf2ad31ecd5208c278ef9f321c6a51a085705ed9 --- /dev/null +++ b/frontend/src/routes/guest/register/steps/consent.tsx @@ -0,0 +1,177 @@ +import { forwardRef, Ref, useImperativeHandle } from 'react' +import { SubmitHandler, useForm, useFieldArray } from 'react-hook-form' + +import { useTranslation } from 'react-i18next' +import { + Box, + Button, + FormControl, + FormGroup, + FormHelperText, + FormLabel, + FormControlLabel, + Checkbox, + Radio, + RadioGroup, + Theme, + Typography, +} from '@mui/material' +import { ConsentType, useConsentTypes } from 'hooks/useConsentTypes' +import { getLocalized } from 'utils' +import { GuestConsentData } from '../enteredGuestData' +import { GuestRegisterCallableMethods } from '../guestRegisterCallableMethods' + +interface GuestConsentProps { + nextHandler(consentData: GuestConsentData): void +} + +const GuestConsentStep = forwardRef( + (props: GuestConsentProps, ref: Ref<GuestRegisterCallableMethods>) => { + const { i18n, t } = useTranslation(['common']) + const { nextHandler } = props + const consentTypes = useConsentTypes() + const { + register, + control, + handleSubmit, + formState: { errors }, + } = useForm<GuestConsentData>() + useFieldArray({ control, name: 'consents' }) + + const submit: SubmitHandler<GuestConsentData> = (data) => { + console.log('consent submit', data) + + nextHandler(data) + } + + const onSubmit = handleSubmit<GuestConsentData>(submit) + useImperativeHandle(ref, () => ({ doSubmit: () => onSubmit() })) + + console.log('errors', errors) + + function showChoices( + consentIndex: number, + consentType: ConsentType, + name: string, + description: string + ) { + const { choices } = consentType + const hasError = + errors && errors.consents && !!errors.consents[consentIndex] + + const header = ( + <> + <FormLabel + sx={{ + fontSize: '1.25rem', + fontWeight: '500', + color: (theme: Theme) => + hasError ? theme.palette.error.main : 'black', + paddingTop: '1.25rem', + paddingBottom: '1rem', + }} + > + {name} + </FormLabel> + <Typography + sx={{ fontWeight: 600 }} + dangerouslySetInnerHTML={{ __html: description }} + /> + </> + ) + + const showError = (text: string) => ( + <FormHelperText sx={{ marginLeft: 0 }}>{text}</FormHelperText> + ) + + if (choices.length === 1) { + return ( + <FormControl component="fieldset" error={hasError}> + {header} + <FormGroup> + <input + type="hidden" + value={consentType.identifier} + {...register(`consents.${consentIndex}.type` as const)} + /> + {choices.map((choice) => { + const choiceText = getLocalized(choice, 'text_', i18n.language) + return ( + <FormControlLabel + key={choice.value} + control={<Checkbox />} + label={choiceText} + value={choice.value} + {...register(`consents.${consentIndex}.choice` as const, { + required: consentType.mandatory, + })} + /> + ) + })}{' '} + </FormGroup> + {hasError && showError(t('validation.consentRequired'))} + </FormControl> + ) + } + return ( + <FormControl component="fieldset" error={hasError}> + {header} + + <RadioGroup> + <input + type="hidden" + value={consentType.identifier} + {...register(`consents.${consentIndex}.type` as const)} + /> + {choices.map((choice) => { + const choiceText = getLocalized(choice, 'text_', i18n.language) + return ( + <FormControlLabel + key={choice.value} + value={choice.value} + control={<Radio />} + label={choiceText} + {...register(`consents.${consentIndex}.choice` as const, { + required: consentType.mandatory, + })} + /> + ) + })} + </RadioGroup> + {hasError && showError(t('validation.consentRequired'))} + </FormControl> + ) + } + + return ( + <Box> + <form onSubmit={onSubmit}> + {consentTypes.map((consentType, consentIndex) => { + const name = + getLocalized(consentType, 'name_', i18n.language) || + consentType.identifier + const description = + getLocalized(consentType, 'description_', i18n.language) || '' + return ( + <Box + sx={{ + borderRadius: '4px', + borderStyle: 'solid', + borderWidth: '1px', + borderColor: (theme: Theme) => theme.palette.text.primary, + padding: '0 1.5rem 1.2rem 1.5rem', + marginBottom: '2.5rem', + }} + > + {showChoices(consentIndex, consentType, name, description)} + </Box> + ) + })} + <Button onClick={onSubmit}>{t('button.save')}</Button> + </form> + </Box> + ) + } +) + +export default GuestConsentStep diff --git a/frontend/src/routes/guest/register/registerPage.test.tsx b/frontend/src/routes/guest/register/steps/register.test.tsx similarity index 80% rename from frontend/src/routes/guest/register/registerPage.test.tsx rename to frontend/src/routes/guest/register/steps/register.test.tsx index 634f2467b358f2858827e208a8c437f4fa8ec2dd..13c7d35cbf729c62a723b808390261c8851ff7c2 100644 --- a/frontend/src/routes/guest/register/registerPage.test.tsx +++ b/frontend/src/routes/guest/register/steps/register.test.tsx @@ -2,10 +2,10 @@ import React from 'react' import { render, screen, waitFor } from 'test-utils' import AdapterDateFns from '@mui/lab/AdapterDateFns' import { LocalizationProvider } from '@mui/lab' -import GuestRegisterStep from './registerPage' -import { EnteredGuestData } from './enteredGuestData' -import AuthenticationMethod from './authenticationMethod' -import { GuestInviteInformation } from './guestDataForm' +import GuestRegisterStep from './register' +import { EnteredGuestData } from '../enteredGuestData' +import AuthenticationMethod from '../authenticationMethod' +import { GuestInviteInformation } from '../guestDataForm' function getEmptyGuestData(): GuestInviteInformation { return { @@ -36,7 +36,7 @@ test('Guest register page showing passport field on manual registration', async <LocalizationProvider dateAdapter={AdapterDateFns}> <GuestRegisterStep nextHandler={nextHandler} - guestData={getEmptyGuestData()} + initialGuestData={getEmptyGuestData()} /> </LocalizationProvider> ) diff --git a/frontend/src/routes/guest/register/registerPage.tsx b/frontend/src/routes/guest/register/steps/register.tsx similarity index 84% rename from frontend/src/routes/guest/register/registerPage.tsx rename to frontend/src/routes/guest/register/steps/register.tsx index 04e55fe60f96da84e105a78546e3d8440f3d0165..60fad8ee988987abc80d08b930995423b5e68025 100644 --- a/frontend/src/routes/guest/register/registerPage.tsx +++ b/frontend/src/routes/guest/register/steps/register.tsx @@ -25,16 +25,16 @@ import { import { getAlpha2Codes, getName } from 'i18n-iso-countries' import { DatePicker } from '@mui/lab' import { subYears } from 'date-fns/fp' -import { GuestInviteInformation } from './guestDataForm' -import { EnteredGuestData } from './enteredGuestData' -import { GuestRegisterCallableMethods } from './guestRegisterCallableMethods' -import { isValidFnr, isValidMobilePhoneNumber } from '../../../utils' -import AuthenticationMethod from './authenticationMethod' +import { isValidFnr, isValidMobilePhoneNumber } from 'utils' +import { GuestInviteInformation } from '../guestDataForm' +import { GuestRegisterData } from '../enteredGuestData' +import { GuestRegisterCallableMethods } from '../guestRegisterCallableMethods' +import AuthenticationMethod from '../authenticationMethod' interface GuestRegisterProperties { - nextHandler(guestData: EnteredGuestData): void - - guestData: GuestInviteInformation + nextHandler(registerData: GuestRegisterData): void + initialGuestData: GuestInviteInformation + registerData: GuestRegisterData | null } /** @@ -45,7 +45,7 @@ interface GuestRegisterProperties { const GuestRegisterStep = forwardRef( (props: GuestRegisterProperties, ref: Ref<GuestRegisterCallableMethods>) => { const { i18n, t } = useTranslation(['common']) - const { nextHandler, guestData } = props + const { nextHandler, initialGuestData, registerData } = props // 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 @@ -58,7 +58,23 @@ const GuestRegisterStep = forwardRef( >(undefined) const [idErrorState, setIdErrorState] = useState<string>('') - const submit: SubmitHandler<EnteredGuestData> = (data) => { + const { + register, + handleSubmit, + setValue, + setError, + clearErrors, + control, + trigger, + formState: { errors }, + } = useForm<GuestRegisterData>({ + defaultValues: registerData ?? {}, + }) + + const submit: SubmitHandler<GuestRegisterData> = async (data) => { + console.log('submit data is', data) + const result = await trigger() + console.log('trigger result is', result) if ( !data.nationalIdNumber && !data.passportNumber && @@ -70,7 +86,7 @@ const GuestRegisterStep = forwardRef( return } - // The user has entered some passport information, check that both nationality and number are present + // if one on the passport fields are set, check that both are set if ( (data.passportNumber && !data.passportNationality) || (!data.passportNumber && data.passportNationality) @@ -80,19 +96,13 @@ const GuestRegisterStep = forwardRef( } setIdErrorState('') - nextHandler(data) - } + console.log('register submit errors', errors) - const { - register, - handleSubmit, - setValue, - setError, - clearErrors, - control, - formState: { errors }, - } = useForm<EnteredGuestData>() - const onSubmit = handleSubmit<EnteredGuestData>(submit) + if (!Object.keys(errors).length) { + nextHandler(data) + } + } + const onSubmit = handleSubmit<GuestRegisterData>(submit) const handlePassportNationalityChange = (event: SelectChangeEvent) => { if (event.target.value) { @@ -147,46 +157,44 @@ const GuestRegisterStep = forwardRef( // Take values coming from the server, if present, and insert them into the form. // This effect has guestData as a dependency, so the data will be reloaded // if guestData changes - setValue('firstName', guestData.first_name) - setValue('lastName', guestData.last_name) + setValue('firstName', initialGuestData.first_name) + setValue('lastName', initialGuestData.last_name) setValue( 'mobilePhone', - guestData.mobile_phone ? guestData.mobile_phone : '' + registerData?.mobilePhone ?? (initialGuestData.mobile_phone || '') ) setValue( 'nationalIdNumber', - guestData.fnr === undefined ? '' : guestData.fnr + initialGuestData.fnr === undefined ? '' : initialGuestData.fnr ) setValue( 'passportNationality', - guestData.passportNationality === undefined + initialGuestData.passportNationality === undefined ? '' - : guestData.passportNationality + : initialGuestData.passportNationality ) - setPassportNationality(guestData.passportNationality) + setPassportNationality(initialGuestData.passportNationality) setValue( 'passportNumber', - guestData.passport === undefined ? '' : guestData.passport + initialGuestData.passport === undefined ? '' : initialGuestData.passport ) - setCountryCode(guestData.countryForCallingCode) + setCountryCode(initialGuestData.countryForCallingCode) setValue( 'mobilePhoneCountry', - guestData.countryForCallingCode ? guestData.countryForCallingCode : '' + initialGuestData.countryForCallingCode + ? initialGuestData.countryForCallingCode + : '' ) - setValue('dateOfBirth', guestData.dateOfBirth) - }, [guestData]) - - function doSubmit() { - return onSubmit() - } + setValue('dateOfBirth', initialGuestData.dateOfBirth) + }, [initialGuestData]) register('mobilePhoneCountry') register('passportNationality') - useImperativeHandle(ref, () => ({ doSubmit })) + useImperativeHandle(ref, () => ({ doSubmit: () => onSubmit() })) return ( <> @@ -206,19 +214,19 @@ const GuestRegisterStep = forwardRef( <form onSubmit={onSubmit}> <Stack spacing={2}> {/* The name is only editable if it is it is not coming from some trusted source */} - {guestData.authentication_method !== + {initialGuestData.authentication_method !== AuthenticationMethod.Invite ? ( <> <TextField id="firstName" label={t('input.firstName')} - value={guestData.first_name} + // value={initialGuestData.first_name} disabled /> <TextField id="lastName" label={t('input.lastName')} - value={guestData.last_name} + value={initialGuestData.last_name} disabled /> </> @@ -229,7 +237,9 @@ const GuestRegisterStep = forwardRef( control={control} defaultValue="" rules={{ - required: true, + required: t( + 'common:validation.firstNameRequired' + ).toString(), }} render={({ field: { onChange, value } }) => ( <TextField @@ -237,6 +247,10 @@ const GuestRegisterStep = forwardRef( label={t('input.firstName')} value={value} onChange={onChange} + error={!!errors.firstName} + helperText={ + errors.firstName && errors.firstName.message + } /> )} /> @@ -245,7 +259,9 @@ const GuestRegisterStep = forwardRef( control={control} defaultValue="" rules={{ - required: true, + required: t( + 'common:validation.lastNameRequired' + ).toString(), }} render={({ field: { onChange, value } }) => ( <TextField @@ -253,6 +269,8 @@ const GuestRegisterStep = forwardRef( label={t('input.lastName')} value={value} onChange={onChange} + error={!!errors.lastName} + helperText={errors.lastName && errors.lastName.message} /> )} /> @@ -287,16 +305,16 @@ const GuestRegisterStep = forwardRef( <TextField id="email" label={t('input.email')} - value={!guestData.email ? '' : guestData.email} + value={initialGuestData.email || ''} disabled /> {/* Only show the Feide ID field if the value is present */} - {guestData.feide_id && ( + {initialGuestData.feide_id && ( <TextField id="feide_id" label={t('feideId')} - value={guestData.feide_id} + value={initialGuestData.feide_id} disabled /> )} @@ -387,7 +405,7 @@ const GuestRegisterStep = forwardRef( )} /> </Box> - {guestData.authentication_method === + {initialGuestData.authentication_method === AuthenticationMethod.Invite && ( <> {/* The guest should fill in one of national ID number or passport number */} @@ -488,7 +506,7 @@ const GuestRegisterStep = forwardRef( </> )} - {guestData.authentication_method === + {initialGuestData.authentication_method === AuthenticationMethod.Feide && ( <TextField id="national_id_number" @@ -508,8 +526,8 @@ const GuestRegisterStep = forwardRef( id="ou-unit" value={ i18n.language === 'en' - ? guestData.ou_name_en - : guestData.ou_name_nb + ? initialGuestData.ou_name_en + : initialGuestData.ou_name_nb } label={t('ou')} disabled @@ -520,8 +538,8 @@ const GuestRegisterStep = forwardRef( label={t('input.roleType')} value={ i18n.language === 'en' - ? guestData.role_name_en - : guestData.role_name_nb + ? initialGuestData.role_name_en + : initialGuestData.role_name_nb } disabled /> @@ -529,7 +547,7 @@ const GuestRegisterStep = forwardRef( <TextField id="rolePeriod" label={t('period')} - value={`${guestData.role_start} - ${guestData.role_end}`} + value={`${initialGuestData.role_start} - ${initialGuestData.role_end}`} disabled /> @@ -538,7 +556,7 @@ const GuestRegisterStep = forwardRef( label={t('input.comment')} multiline rows={5} - value={guestData.comment} + value={initialGuestData.comment} disabled /> </Stack> diff --git a/frontend/src/routes/guest/register/submitSuccessPage.tsx b/frontend/src/routes/guest/register/steps/success.tsx similarity index 90% rename from frontend/src/routes/guest/register/submitSuccessPage.tsx rename to frontend/src/routes/guest/register/steps/success.tsx index 1c7e394612f1cb31ea7916795b21afbf23ba8e73..7ac97a900dfc7d178ecfd6db499e9b7b92da7161 100644 --- a/frontend/src/routes/guest/register/submitSuccessPage.tsx +++ b/frontend/src/routes/guest/register/steps/success.tsx @@ -1,11 +1,10 @@ import React from 'react' import { useTranslation } from 'react-i18next' +import { useHistory } from 'react-router-dom' import { Box, Button } from '@mui/material' -import { useHistory } from 'react-router-dom' - -const StepSubmitSuccessGuest = () => { +const GuestSuccessStep = () => { const { t } = useTranslation(['common']) const history = useHistory() @@ -38,4 +37,4 @@ const StepSubmitSuccessGuest = () => { ) } -export default StepSubmitSuccessGuest +export default GuestSuccessStep diff --git a/frontend/src/routes/invitelink/index.tsx b/frontend/src/routes/invitelink/index.tsx index cfaa21ed82dff761a07eb20afbd40b852e8ee854..fd8d63f056582d59de1a1774688a6d13506e8ec2 100644 --- a/frontend/src/routes/invitelink/index.tsx +++ b/frontend/src/routes/invitelink/index.tsx @@ -11,7 +11,7 @@ function InviteLink() { useEffect(() => { fetch('/api/ui/v1/invitecheck/', submitJsonOpts('POST', { uuid: id })) }, []) - setCookie('redirect', 'guestregister') + setCookie('redirect', '/guestregister') return <Redirect to="/invite" /> } diff --git a/frontend/src/routes/sponsor/register/frontPage.tsx b/frontend/src/routes/sponsor/register/frontPage.tsx index 21b38e57ec36fcda71249d411f5c1bcf10a20668..4f0fbb012e88bd95d0c2aea2115d3772f04c4366 100644 --- a/frontend/src/routes/sponsor/register/frontPage.tsx +++ b/frontend/src/routes/sponsor/register/frontPage.tsx @@ -70,7 +70,7 @@ function FrontPage() { variant="contained" color="secondary" component={Link} - to="register/new" + to="/register/new" > {t('register.registerButtonText')} </Button> diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 88b23193afdf34bca4ddb5f26a20dd3227d518f1..2e993f03bff6a5cfd9a328ecae548157973b36e4 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -55,6 +55,15 @@ export function submitJsonOpts(method: string, data: object): RequestInit { } } +export function fetchJsonOpts(): RequestInit { + return { + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + } +} + export function isValidFnr( data: string | undefined, allowEmpty = false @@ -110,6 +119,15 @@ export function splitPhoneNumber(phoneNumber: string): [string, string] { ] } +export function getLocalized( + obj: any, + prefix: string, + locale: string +): string | null { + const key = prefix + locale + return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : null +} + export function parseRole(role: FetchedRole): Role { return { id: role.id, diff --git a/greg/admin.py b/greg/admin.py index c19389dce61a8aa022051be0338ce6a6f0e0d6ca..c850afc5309afa8dbd95e8acedeafa01a265b6e4 100644 --- a/greg/admin.py +++ b/greg/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.contrib.admin import display from reversion.admin import VersionAdmin from greg.models import ( @@ -10,8 +11,9 @@ from greg.models import ( Role, RoleType, Identity, - ConsentType, Consent, + ConsentChoice, + ConsentType, OrganizationalUnit, Sponsor, SponsorOrganizationalUnit, @@ -35,6 +37,11 @@ class ConsentInline(admin.TabularInline): extra = 1 +class ConsentChoiceInline(admin.TabularInline): + model = ConsentChoice + extra = 1 + + class PersonAdmin(VersionAdmin): list_display = ( "first_name", @@ -52,11 +59,23 @@ class PersonAdmin(VersionAdmin): class RoleAdmin(VersionAdmin): - list_display = ("id", "person", "type") + list_display = ( + "id", + "person", + "type", + "start_date", + "end_date", + "orgunit", + "sponsor", + ) search_fields = ( "person__id", + "person__first_name", + "person__last_name", "type__id", + "type__identifier", ) + list_filter = ("type",) raw_id_fields = ("person", "type") readonly_fields = ("id", "created", "updated") @@ -76,11 +95,10 @@ class ConsentAdmin(VersionAdmin): list_display = ("id", "person", "get_consent_type_name") readonly_fields = ("id", "created", "updated") + @display(description="Consent name") def get_consent_type_name(self, obj): return obj.type.name_en - get_consent_type_name.short_description = "Consent name" # type: ignore - class ConsentTypeAdmin(VersionAdmin): list_display = ( @@ -91,6 +109,7 @@ class ConsentTypeAdmin(VersionAdmin): "mandatory", ) readonly_fields = ("id", "created", "updated") + inlines = (ConsentChoiceInline,) class OuIdentifierInline(admin.TabularInline): @@ -126,7 +145,19 @@ class SponsorOrganizationalUnitAdmin(VersionAdmin): class InvitationAdmin(VersionAdmin): - list_display = ("id",) + list_display = ("id", "role", "get_role_person", "get_role_type", "get_role_ou") + + @display(description="Person") + def get_role_person(self, obj): + return obj.role.person + + @display(description="Role type") + def get_role_type(self, obj): + return obj.role.type + + @display(description="OU") + def get_role_ou(self, obj): + return obj.role.orgunit class InvitationLinkAdmin(VersionAdmin): diff --git a/greg/api/serializers/consent_type.py b/greg/api/serializers/consent_type.py index b7a5da36b436c671175dd9b636a6e49da86a46b0..407777f6026e7ae0602988889657b2e764d2b71c 100644 --- a/greg/api/serializers/consent_type.py +++ b/greg/api/serializers/consent_type.py @@ -1,12 +1,33 @@ from rest_framework.serializers import ModelSerializer -from greg.models import ConsentType +from greg.models import ConsentChoice, ConsentType + + +class ConsentChoiceSerializer(ModelSerializer): + class Meta: + model = ConsentChoice + fields = ["value", "text_en", "text_nb", "text_nn"] class ConsentTypeSerializer(ModelSerializer): + choices = ConsentChoiceSerializer(many=True) + class Meta: model = ConsentType - fields = "__all__" + fields = ( + "id", + "identifier", + "name_en", + "name_nb", + "name_nn", + "description_en", + "description_nb", + "description_nn", + "mandatory", + "user_allowed_to_change", + "valid_from", + "choices", + ) class ConsentTypeSerializerBrief(ModelSerializer): diff --git a/greg/migrations/0016_consent_choices.py b/greg/migrations/0016_consent_choices.py new file mode 100644 index 0000000000000000000000000000000000000000..de53dbe7facbd007455e2e54083f210410dff115 --- /dev/null +++ b/greg/migrations/0016_consent_choices.py @@ -0,0 +1,58 @@ +# Generated by Django 3.2.9 on 2021-11-23 08:09 + +import dirtyfields.dirtyfields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('greg', '0015_add_feide_email'), + ] + + operations = [ + migrations.RemoveField( + model_name='consenttype', + name='link_en', + ), + migrations.RemoveField( + model_name='consenttype', + name='link_nb', + ), + migrations.AddField( + model_name='consenttype', + name='description_nn', + field=models.TextField(default=''), + preserve_default=False, + ), + migrations.AddField( + model_name='consenttype', + name='name_nn', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + migrations.CreateModel( + name='ConsentChoice', + 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)), + ('value', models.CharField(max_length=128)), + ('text_en', models.CharField(max_length=512)), + ('text_nb', models.CharField(max_length=512)), + ('text_nn', models.CharField(max_length=512)), + ('consent_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='choices', to='greg.consenttype')), + ], + bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), + ), + migrations.AddField( + model_name='consent', + name='choice', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='consents', to='greg.consentchoice'), + ), + migrations.AddConstraint( + model_name='consentchoice', + constraint=models.UniqueConstraint(fields=('consent_type', 'value'), name='consent_choice_type_value_unique'), + ), + ] diff --git a/greg/models.py b/greg/models.py index 83b64287984912fe3d870e05b5178c15b08e3815..6591172b3150e20b38823ddcdfa7349d8ce204cd 100644 --- a/greg/models.py +++ b/greg/models.py @@ -329,10 +329,10 @@ class ConsentType(BaseModel): identifier = models.SlugField(max_length=64, unique=True) name_en = models.CharField(max_length=256) name_nb = models.CharField(max_length=256) + name_nn = models.CharField(max_length=256) description_en = models.TextField() description_nb = models.TextField() - link_en = models.URLField(null=True) - link_nb = models.URLField(null=True) + description_nn = models.TextField() valid_from = models.DateField(default=date.today) user_allowed_to_change = models.BooleanField() mandatory = models.BooleanField(default=False) @@ -351,6 +351,42 @@ class ConsentType(BaseModel): ) +class ConsentChoice(BaseModel): + """ + Describes an option associated with a consent type. + """ + + consent_type = models.ForeignKey( + "ConsentType", on_delete=models.PROTECT, related_name="choices" + ) + value = models.CharField(max_length=128) + text_en = models.CharField(max_length=512) + text_nb = models.CharField(max_length=512) + text_nn = models.CharField(max_length=512) + + class Meta: + constraints = ( + models.UniqueConstraint( + fields=["consent_type", "value"], + name="consent_choice_type_value_unique", + ), + ) + + def __str__(self): + return "{} ({})".format(str(self.text_en or self.text_nb), self.value) + + def __repr__(self): + return "{}(id={!r}, consent_type={!r} value={!r}, text_en={!r}, text_nb={!r}, text_nn={!r})".format( + self.__class__.__name__, + self.pk, + self.consent_type, + self.value, + self.text_en, + self.text_nb, + self.text_nn, + ) + + class Consent(BaseModel): """ Links a person and a consent he has given. @@ -362,7 +398,12 @@ class Consent(BaseModel): type = models.ForeignKey( "ConsentType", on_delete=models.PROTECT, related_name="persons" ) - # If the date is blank it means the person has not given consent yet + choice = models.ForeignKey( + "ConsentChoice", + on_delete=models.PROTECT, + related_name="consents", + null=True, + ) consent_given_at = models.DateField(null=True) class Meta: diff --git a/greg/tests/api/test_consent_type.py b/greg/tests/api/test_consent_type.py index 42cdb8d4894a3f1676d608895bc6186bde68742c..7851f858c95a295d9aa1d035071746fc9472351f 100644 --- a/greg/tests/api/test_consent_type.py +++ b/greg/tests/api/test_consent_type.py @@ -12,6 +12,30 @@ def test_get_consent_type(client, consent_type_foo: ConsentType): ) assert resp.status_code == status.HTTP_200_OK data = resp.json() - assert data.get("id") == consent_type_foo.id - assert data.get("identifier") == consent_type_foo.identifier - assert data.get("name_en") == consent_type_foo.name_en + assert data == { + "id": consent_type_foo.id, + "identifier": consent_type_foo.identifier, + "name_en": consent_type_foo.name_en, + "name_nb": consent_type_foo.name_nb, + "name_nn": consent_type_foo.name_nn, + "description_en": consent_type_foo.description_en, + "description_nb": consent_type_foo.description_nb, + "description_nn": consent_type_foo.description_nn, + "mandatory": False, + "user_allowed_to_change": True, + "valid_from": "2018-01-20", + "choices": [ + { + "value": "yes", + "text_en": "Yes", + "text_nb": "Ja", + "text_nn": "Ja", + }, + { + "value": "no", + "text_en": "No", + "text_nb": "Nei", + "text_nn": "Nei", + }, + ], + } diff --git a/greg/tests/conftest.py b/greg/tests/conftest.py index 04dfe76cb87facce8fa4db2e09cd8b09c772c34d..8315054552d2a98a70dd77e34af6991e5b80d5e8 100644 --- a/greg/tests/conftest.py +++ b/greg/tests/conftest.py @@ -9,6 +9,7 @@ import pytest from greg.models import ( Consent, + ConsentChoice, Notification, OuIdentifier, Person, @@ -167,21 +168,6 @@ def role_person_foo( return Role.objects.get(id=role.id) -@pytest.fixture -def consent_type() -> ConsentType: - ct = ConsentType.objects.create( - identifier="it-guidelines", - name_en="IT Guidelines", - name_nb="IT Regelverk", - description_en="IT Guidelines description", - description_nb="IT Regelverk beskrivelse", - link_en="https://example.org/it-guidelines", - link_nb="https://example.org/it-guidelines", - user_allowed_to_change=False, - ) - return ConsentType.objects.get(id=ct.id) - - @pytest.fixture def role_type_foo() -> RoleType: rt = RoleType.objects.create(identifier="role_foo", name_en="Role Foo") @@ -195,27 +181,43 @@ def role_type_bar() -> RoleType: @pytest.fixture -def consent_fixture(person, consent_type): +def consent_fixture(person, consent_type_foo): consent_given_date = "2021-06-20" Consent.objects.create( - person=person, type=consent_type, consent_given_at=consent_given_date + person=person, type=consent_type_foo, consent_given_at=consent_given_date ) return Consent.objects.get(id=1) @pytest.fixture def consent_type_foo() -> ConsentType: - return ConsentType.objects.create( - identifier="test_consent", - name_en="Test1", - name_nb="Test2", - description_en="Test description", - description_nb="Test beskrivelse", - link_en="https://example.org", - link_nb="https://example.org", + consent_foo = ConsentType.objects.create( + identifier="foo", + name_en="Foo", + name_nb="Fu", + name_nn="F", + description_en="Description", + description_nb="Beskrivelse", + description_nn="Beskriving", valid_from="2018-01-20", user_allowed_to_change=True, + mandatory=False, + ) + ConsentChoice.objects.create( + consent_type=consent_foo, + value="yes", + text_en="Yes", + text_nb="Ja", + text_nn="Ja", + ) + ConsentChoice.objects.create( + consent_type=consent_foo, + value="no", + text_en="No", + text_nb="Nei", + text_nn="Nei", ) + return ConsentType.objects.get(id=consent_foo.id) @pytest.fixture diff --git a/greg/tests/models/test_consent.py b/greg/tests/models/test_consent.py index 9efa4b68ddf6c04c19fbe74c1164b66b46d7e293..94745e2b9d6083c645ec6b4b7052b7302c7ead20 100644 --- a/greg/tests/models/test_consent.py +++ b/greg/tests/models/test_consent.py @@ -10,35 +10,34 @@ from greg.models import ( @pytest.mark.django_db -def test_add_consent_to_person(person: Person, consent_type: ConsentType): +def test_add_consent_to_person(person: Person, consent_type_foo: ConsentType): consent_given_date = "2021-06-20" Consent.objects.create( - person=person, type=consent_type, consent_given_at=consent_given_date + person=person, type=consent_type_foo, consent_given_at=consent_given_date ) consents = Consent.objects.filter(person_id=person.id) assert len(consents) == 1 assert consents[0].person_id == person.id - assert consents[0].type_id == consent_type.id + assert consents[0].type_id == consent_type_foo.id assert consents[0].consent_given_at == datetime.date(2021, 6, 20) @pytest.mark.django_db def test_add_not_acknowledged_consent_to_person( - person: Person, consent_type: ConsentType + person: Person, consent_type_foo: ConsentType ): - Consent.objects.create(person=person, type=consent_type) + Consent.objects.create(person=person, type=consent_type_foo) consents = Consent.objects.filter(person_id=person.id) assert len(consents) == 1 assert consents[0].person_id == person.id - assert consents[0].type_id == consent_type.id + assert consents[0].type_id == consent_type_foo.id assert consents[0].consent_given_at is None @pytest.mark.django_db def test_consent_repr(consent_fixture): - today = datetime.date.today() assert ( repr(consent_fixture) - == f"Consent(id=1, person=Person(id=1, first_name='Test', last_name='Tester'), type=ConsentType(id=1, identifier='it-guidelines', name_en='IT Guidelines', valid_from={today!r}, user_allowed_to_change=False), consent_given_at=datetime.date(2021, 6, 20))" + == "Consent(id=1, person=Person(id=1, first_name='Test', last_name='Tester'), type=ConsentType(id=1, identifier='foo', name_en='Foo', valid_from=datetime.date(2018, 1, 20), user_allowed_to_change=True), consent_given_at=datetime.date(2021, 6, 20))" ) diff --git a/greg/tests/models/test_consent_type.py b/greg/tests/models/test_consent_type.py new file mode 100644 index 0000000000000000000000000000000000000000..5edfcda3cb3112d7c6bf2faf468f9ed1da984b2b --- /dev/null +++ b/greg/tests/models/test_consent_type.py @@ -0,0 +1,14 @@ +import pytest + + +@pytest.mark.django_db +def test_consent_type_repr(consent_type_foo): + assert ( + repr(consent_type_foo) + == "ConsentType(id=1, identifier='foo', name_en='Foo', valid_from=datetime.date(2018, 1, 20), user_allowed_to_change=True)" + ) + + +@pytest.mark.django_db +def test_consent_type_str(consent_type_foo): + assert str(consent_type_foo) == "Foo (foo)" diff --git a/greg/tests/models/test_consenttype.py b/greg/tests/models/test_consenttype.py deleted file mode 100644 index d69e340b417f95c560a9cd907c1d28f3fec47b1a..0000000000000000000000000000000000000000 --- a/greg/tests/models/test_consenttype.py +++ /dev/null @@ -1,17 +0,0 @@ -from datetime import date - -import pytest - - -@pytest.mark.django_db -def test_consenttype_repr(consent_type): - today = date.today() - assert ( - repr(consent_type) - == f"ConsentType(id=1, identifier='it-guidelines', name_en='IT Guidelines', valid_from={today!r}, user_allowed_to_change=False)" - ) - - -@pytest.mark.django_db -def test_consenttype_str(consent_type): - assert str(consent_type) == "IT Guidelines (it-guidelines)" diff --git a/greg/tests/populate_database.py b/greg/tests/populate_database.py index f4e103f1d569a766b3fcc2fb237cdb32bd289716..3ac7e25cbf74dff9a2099c67188f23b057eaf346 100644 --- a/greg/tests/populate_database.py +++ b/greg/tests/populate_database.py @@ -100,10 +100,10 @@ class DatabasePopulation: identifier=self.faker.slug(), name_en=self.faker.sentence(nb_words=6), name_nb=self.faker.sentence(nb_words=6), + name_nn=self.faker.sentence(nb_words=6), description_en=self.faker.paragraph(nb_sentences=5), description_nb=self.faker.paragraph(nb_sentences=5), - link_en=self.faker.url(), - link_nb=self.faker.url(), + description_nn=self.faker.paragraph(nb_sentences=5), user_allowed_to_change=self.random.random() > 0.5, ) ) diff --git a/greg/tests/populate_fixtures.py b/greg/tests/populate_fixtures.py index 881253178e8c9454dd792088c70c031e615370e5..754c59f58d0d4a19233b6694c58de33b0e2bba91 100644 --- a/greg/tests/populate_fixtures.py +++ b/greg/tests/populate_fixtures.py @@ -30,6 +30,7 @@ from django.utils import timezone from greg.models import ( Consent, + ConsentChoice, ConsentType, Identity, Invitation, @@ -69,6 +70,7 @@ class DatabasePopulation: with connection.cursor() as cursor: for table in ( "greg_consent", + "greg_consentchoice", "greg_consenttype", "greg_notification", "greg_identity", @@ -79,28 +81,54 @@ class DatabasePopulation: "greg_roletype", "greg_ouidentifier", "greg_organizationalunit", - "greg_person", "gregui_greguserprofile", + "greg_person", "greg_sponsor", ): logging.info("purging table %s", table) cursor.execute(f"DELETE FROM {table}") logger.info("...tables purged") - def _add_consenttypes(self): - ConsentType.objects.create( + def _add_consent_types_and_choices(self): + mandatory = ConsentType.objects.create( identifier=CONSENT_IDENT_MANDATORY, - name_en="Mandatory consent type", - name_nb="PÃ¥krevd samtykketype", + name_en="Mandatory consent", + name_nb="Obligatorisk samtykke", + name_nn="Obligatorisk samtykke", + description_en="Description for mandatory consent", + description_nb="Beskrivelse for obligatorisk samtykke", + description_nn="Forklaring for obligatorisk samtykke", user_allowed_to_change=False, mandatory=True, ) - ConsentType.objects.create( + ConsentChoice.objects.create( + consent_type=mandatory, + value="yes", + text_en="Yes", + text_nb="Ja", + text_nn="Ja", + ) + optional = ConsentType.objects.create( identifier=CONSENT_IDENT_OPTIONAL, - name_en="Optional consent type", - name_nb="Valgfri samtykketype", + name_en="Optional consent", + name_nb="Valgfritt samtykke", + name_nn="Valfritt samtykke", user_allowed_to_change=False, ) + ConsentChoice.objects.create( + consent_type=optional, + value="yes", + text_en="Yes", + text_nb="Ja", + text_nn="Ja", + ) + ConsentChoice.objects.create( + consent_type=optional, + value="no", + text_en="No", + text_nb="Nei", + text_nn="Nei", + ) def _add_ous_with_identifiers(self): """ @@ -341,7 +369,7 @@ class DatabasePopulation: def populate_database(self): logger.info("populating db...") # Add the types, sponsors and ous - self._add_consenttypes() + self._add_consent_types_and_choices() self._add_ous_with_identifiers() self._add_roletypes() self._add_sponsors() diff --git a/greg/tests/test_notifications.py b/greg/tests/test_notifications.py index c0fd917da896d0866d72b3dc0729bc357c1a5014..3e5d4ac29861647d81b2e8e6bf66cf50ec7313e9 100644 --- a/greg/tests/test_notifications.py +++ b/greg/tests/test_notifications.py @@ -145,9 +145,9 @@ def test_role_delete_notification( @pytest.mark.django_db -def test_consent_add_notification(person: Person, consent_type: ConsentType): +def test_consent_add_notification(person: Person, consent_type_foo: ConsentType): consent = Consent.objects.create( - person=person, type=consent_type, consent_given_at="2021-06-20" + person=person, type=consent_type_foo, consent_given_at="2021-06-20" ) notifications = Notification.objects.filter(object_type="Consent") assert len(notifications) == 1 @@ -160,11 +160,11 @@ def test_consent_add_notification(person: Person, consent_type: ConsentType): @pytest.mark.django_db -def test_consent_update_notification(person: Person, consent_type: ConsentType): +def test_consent_update_notification(person: Person, consent_type_foo: ConsentType): consent = Consent.objects.create( - person=person, type=consent_type, consent_given_at="2021-06-20" + person=person, type=consent_type_foo, consent_given_at="2021-06-20" ) - consents = Consent.objects.filter(person=person, type=consent_type) + consents = Consent.objects.filter(person=person, type=consent_type_foo) consents[0].consent_given_at = "2021-06-21" consents[0].save() @@ -179,11 +179,11 @@ def test_consent_update_notification(person: Person, consent_type: ConsentType): @pytest.mark.django_db -def test_consent_delete_notification(person: Person, consent_type: ConsentType): +def test_consent_delete_notification(person: Person, consent_type_foo: ConsentType): consent = Consent.objects.create( - person=person, type=consent_type, consent_given_at="2021-06-20" + person=person, type=consent_type_foo, consent_given_at="2021-06-20" ) - consents = Consent.objects.filter(person=person, type=consent_type) + consents = Consent.objects.filter(person=person, type=consent_type_foo) consents[0].delete() notifications = Notification.objects.filter(object_type="Consent") @@ -193,8 +193,8 @@ def test_consent_delete_notification(person: Person, consent_type: ConsentType): meta = notifications[0].meta assert meta["person_id"] == person.id assert meta["consent_id"] == consent.id - assert meta["consent_type"] == consent_type.identifier - assert meta["consent_type_id"] == consent_type.id + assert meta["consent_type"] == consent_type_foo.identifier + assert meta["consent_type_id"] == consent_type_foo.id @pytest.mark.django_db diff --git a/gregui/api/serializers/guest.py b/gregui/api/serializers/guest.py index 112b200fed9915223488070fa19723484387c3e8..471f459de8c20b07ed0860f9ee088ecb5ed2b985 100644 --- a/gregui/api/serializers/guest.py +++ b/gregui/api/serializers/guest.py @@ -1,13 +1,29 @@ import datetime +from django.utils.timezone import now from rest_framework import serializers +from rest_framework.exceptions import ValidationError -from greg.models import Identity, Person +from greg.models import Consent, ConsentChoice, ConsentType, Identity, Person from gregui.validation import ( validate_phone_number, validate_norwegian_national_id_number, ) +# pylint: disable=W0223 +class GuestConsentChoiceSerializer(serializers.Serializer): + type = serializers.CharField(required=True) + choice = serializers.CharField(required=True) + + def validate(self, attrs): + """Check that the combination of consent type and choice exists.""" + choice = ConsentChoice.objects.filter( + consent_type__identifier=attrs["type"], value=attrs["choice"] + ) + if not choice.exists(): + raise serializers.ValidationError("invalid consent type or choice") + return attrs + class GuestRegisterSerializer(serializers.ModelSerializer): first_name = serializers.CharField(required=False, min_length=1) @@ -23,6 +39,7 @@ class GuestRegisterSerializer(serializers.ModelSerializer): ) passport = serializers.CharField(required=False) date_of_birth = serializers.DateField(required=False) + consents = GuestConsentChoiceSerializer(required=False, many=True, write_only=True) def update(self, instance, validated_data): if "email" in validated_data: @@ -59,8 +76,35 @@ class GuestRegisterSerializer(serializers.ModelSerializer): if "date_of_birth" in validated_data: instance.date_of_birth = validated_data["date_of_birth"] + consents = validated_data.get("consents", {}) + self._handle_consents(person=instance, consents=consents) + return instance + def _handle_consents(self, person, consents): + consent_types = [x["type"] for x in consents] + mandatory_consents = ConsentType.objects.filter(mandatory=True).values_list( + "identifier", flat=True + ) + missing_consents = list(set(mandatory_consents) - set(consent_types)) + if missing_consents: + raise ValidationError(f"missing mandatory consents {missing_consents}") + + for consent in consents: + consent_type = ConsentType.objects.get(identifier=consent["type"]) + choice = ConsentChoice.objects.get( + consent_type=consent_type, value=consent["choice"] + ) + consent_instance, created = Consent.objects.get_or_create( + type=consent_type, + person=person, + choice=choice, + defaults={"consent_given_at": now()}, + ) + if not created and consent_instance.choice != choice: + consent_instance.choice = choice + consent_instance.save() + def validate_date_of_birth(self, date_of_birth): today = datetime.date.today() @@ -85,6 +129,7 @@ class GuestRegisterSerializer(serializers.ModelSerializer): "fnr", "passport", "date_of_birth", + "consents", ) read_only_fields = ("id",) diff --git a/gregui/api/urls.py b/gregui/api/urls.py index d7fa5f1febaa1b411ae31c6ed5e84fbe4dd587c6..3b991355256b6eb6fb093284013e50408fa90aca 100644 --- a/gregui/api/urls.py +++ b/gregui/api/urls.py @@ -11,6 +11,7 @@ from gregui.api.views.invitation import ( from gregui.api.views.ou import OusViewSet from gregui.api.views.person import GuestInfoViewSet, PersonSearchViewSet, PersonViewSet from gregui.api.views.role import RoleInfoViewSet +from gregui.api.views.consent import ConsentTypeViewSet from gregui.api.views.roletypes import RoleTypeViewSet from gregui.api.views.unit import UnitsViewSet @@ -23,6 +24,7 @@ router.register(r"guests/", GuestInfoViewSet, basename="guests") urlpatterns = router.urls urlpatterns += [ + re_path(r"consenttypes/$", ConsentTypeViewSet.as_view(), name="consent-types"), 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="invited-info"), diff --git a/gregui/api/views/consent.py b/gregui/api/views/consent.py new file mode 100644 index 0000000000000000000000000000000000000000..f9f549c47bdb9e59b5bcd001943847c66ed867d5 --- /dev/null +++ b/gregui/api/views/consent.py @@ -0,0 +1,11 @@ +from rest_framework import permissions +from rest_framework.generics import ListAPIView + +from greg.models import ConsentType +from greg.api.serializers.consent_type import ConsentTypeSerializer + + +class ConsentTypeViewSet(ListAPIView): + queryset = ConsentType.objects.all().order_by("id") + permission_classes = [permissions.AllowAny] + serializer_class = ConsentTypeSerializer diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py index 88531650500f689621c08b79a0e9214aeaa3a7e3..af633e83e82470f5e0faffd8dda283319fec4a22 100644 --- a/gregui/api/views/invitation.py +++ b/gregui/api/views/invitation.py @@ -1,6 +1,6 @@ import logging from enum import Enum -from typing import Optional +from typing import Optional, List from django.core import exceptions from django.db import transaction @@ -154,8 +154,9 @@ class InvitedGuestView(GenericAPIView): "mobile_phone", "passport", "date_of_birth", + "consents", ] - fields_allowed_to_update_if_feide = ["mobile_phone"] + fields_allowed_to_update_if_feide = ["mobile_phone", "consents"] def get(self, request, *args, **kwargs): """ @@ -238,13 +239,18 @@ class InvitedGuestView(GenericAPIView): # If there is a Feide ID registered with the guest, assume that the name is also coming from there feide_id = self._get_identity_or_none(person, Identity.IdentityType.FEIDE_ID) - if not self._only_allowed_fields_in_request( + + illegal_fields = self._illegal_fields( data, self.fields_allowed_to_update_if_invite if feide_id is None else self.fields_allowed_to_update_if_feide, - ): - return Response(status=status.HTTP_400_BAD_REQUEST) + ) + if illegal_fields: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"error": {"cannot_update_fields": illegal_fields}}, + ) if self._verified_fnr_already_exists(person) and fnr: # The user should not be allowed to change a verified fnr @@ -281,14 +287,11 @@ class InvitedGuestView(GenericAPIView): return False @staticmethod - def _only_allowed_fields_in_request(request_data, fields_allowed_to_update) -> bool: + def _illegal_fields(request_data, changeable_fields) -> List[str]: # Check how many of the allowed fields are filled in person_data = request_data["person"] - number_of_fields_filled_in = sum( - map(lambda x: x in person_data.keys(), fields_allowed_to_update) - ) - # Check that there are no other fields filled in - return number_of_fields_filled_in == len(person_data.keys()) + changed_fields = person_data.keys() + return list(set(changed_fields) - set(changeable_fields)) @staticmethod def _get_identity_or_none( diff --git a/gregui/tests/api/views/test_consent.py b/gregui/tests/api/views/test_consent.py new file mode 100644 index 0000000000000000000000000000000000000000..6da9cee54f5a579c33496231234ef09b3bc45594 --- /dev/null +++ b/gregui/tests/api/views/test_consent.py @@ -0,0 +1,45 @@ +import pytest + +from rest_framework import status +from rest_framework.reverse import reverse + + +@pytest.mark.django_db +def test_get_consents(client, consent_type_foo, consent_type_bar): + response = client.get(reverse("gregui-v1:consent-types")) + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + { + "choices": [ + {"text_en": "Yes", "text_nb": "Ja", "text_nn": "Ja", "value": "yes"}, + {"text_en": "No", "text_nb": "Nei", "text_nn": "Nei", "value": "no"}, + ], + "description_en": "Description", + "description_nb": "Beskrivelse", + "description_nn": "Beskriving", + "id": 1, + "identifier": "foo", + "mandatory": False, + "name_en": "Foo", + "name_nb": "Fu", + "name_nn": "F", + "user_allowed_to_change": True, + "valid_from": "2018-01-20", + }, + { + "choices": [ + {"text_en": "Yes", "text_nb": "Ja", "text_nn": "Ja", "value": "yes"} + ], + "description_en": "Description", + "description_nb": "Beskrivelse", + "description_nn": "Beskriving", + "id": 2, + "identifier": "bar", + "mandatory": True, + "name_en": "Bar", + "name_nb": "Ba", + "name_nn": "B", + "user_allowed_to_change": True, + "valid_from": "2018-01-20", + }, + ] diff --git a/gregui/tests/api/views/test_invitation.py b/gregui/tests/api/views/test_invitation.py index bd1e7d172e1a487c3c2a8e22f45b0c88a53f6e93..5883fea7c4ef326cb93e586e7b14a369a0d02bda 100644 --- a/gregui/tests/api/views/test_invitation.py +++ b/gregui/tests/api/views/test_invitation.py @@ -6,7 +6,7 @@ from rest_framework import status from rest_framework.reverse import reverse from rest_framework.test import APIClient -from greg.models import InvitationLink, Person, Identity +from greg.models import Consent, InvitationLink, Person, Identity @pytest.mark.django_db @@ -209,7 +209,7 @@ def test_post_invited_info_valid_national_id_number(client, invited_person): response = client.post(url, data, format="json") - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_200_OK, response.data person.refresh_from_db() assert person.fnr.value == fnr @@ -229,7 +229,7 @@ def test_email_update(client, invited_person): data = {"person": {"mobile_phone": "+4797543992", "email": "test2@example.com"}} response = client.post(url, data, format="json") - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_200_OK, response.data person.private_email.refresh_from_db() assert person.private_email.value == "test2@example.com" @@ -255,7 +255,7 @@ def test_register_passport(client, invited_person): response = client.post(url, data, format="json") - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_200_OK, response.data person.refresh_from_db() registered_passport = Identity.objects.filter( @@ -346,7 +346,7 @@ def test_name_update_allowed_if_feide_identity_is_not_present( data, format="json", ) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_200_OK, response.data # Check that the name has been updated in the database assert Person.objects.count() == 1 @@ -371,6 +371,7 @@ def test_post_info_fail_fnr_already_verified(client, invited_person_verified_nin # Verify rejection assert response.status_code == status.HTTP_400_BAD_REQUEST + # Verify fnr was not changed person.refresh_from_db() assert person.fnr.value != fnr @@ -432,3 +433,38 @@ def test_invalid_date_of_birth_rejected(client, invited_person_verified_nin): person = Person.objects.get() assert person.date_of_birth is None + response = client.post(url, data, format="json") + + +@pytest.mark.django_db +def test_saves_consents(client, invited_person, consent_type_foo, consent_type_bar): + person, invitation_link = invited_person + fnr = "11120618212" + + session = client.session + session["invite_id"] = str(invitation_link.uuid) + session.save() + + consents = [ + {"type": consent_type_foo.identifier, "choice": "no"}, + {"type": consent_type_bar.identifier, "choice": "yes"}, + ] + + data = {"person": {"mobile_phone": "+4797543992", "fnr": fnr, "consents": consents}} + url = reverse("gregui-v1:invited-info") + response = client.post(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK, response.data + person.refresh_from_db() + + assert Consent.objects.filter( + person=person, + type=consent_type_foo, + choice=consent_type_foo.choices.filter(value="no").first(), + ).exists() + + assert Consent.objects.filter( + person=person, + type=consent_type_bar, + choice=consent_type_bar.choices.filter(value="yes").first(), + ).exists() diff --git a/gregui/tests/conftest.py b/gregui/tests/conftest.py index 797760461eb0c73c363961cff2ebecbc697d8801..f30b5e001cfd65a8abe341975578cc70a12c1ac1 100644 --- a/gregui/tests/conftest.py +++ b/gregui/tests/conftest.py @@ -13,6 +13,8 @@ from rest_framework.authtoken.admin import User from rest_framework.test import APIClient from greg.models import ( + ConsentType, + ConsentChoice, Identity, Invitation, InvitationLink, @@ -490,3 +492,58 @@ You have been registered as a guest at {{ institution }} by {{ sponsor }}. To complete the registration of your guest account, please follow this link: {{ registration_link }}""", ) return EmailTemplate.objects.get(id=et.id) + + +@pytest.fixture +def consent_type_foo() -> ConsentType: + type_foo = ConsentType.objects.create( + identifier="foo", + name_en="Foo", + name_nb="Fu", + name_nn="F", + description_en="Description", + description_nb="Beskrivelse", + description_nn="Beskriving", + valid_from="2018-01-20", + user_allowed_to_change=True, + mandatory=False, + ) + ConsentChoice.objects.create( + consent_type=type_foo, + value="yes", + text_en="Yes", + text_nb="Ja", + text_nn="Ja", + ) + ConsentChoice.objects.create( + consent_type=type_foo, + value="no", + text_en="No", + text_nb="Nei", + text_nn="Nei", + ) + return ConsentType.objects.get(id=type_foo.id) + + +@pytest.fixture +def consent_type_bar() -> ConsentType: + type_bar = ConsentType.objects.create( + identifier="bar", + name_en="Bar", + name_nb="Ba", + name_nn="B", + description_en="Description", + description_nb="Beskrivelse", + description_nn="Beskriving", + valid_from="2018-01-20", + user_allowed_to_change=True, + mandatory=True, + ) + ConsentChoice.objects.create( + consent_type=type_bar, + value="yes", + text_en="Yes", + text_nb="Ja", + text_nn="Ja", + ) + return ConsentType.objects.get(id=type_bar.id)