diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 833e4b472217f31f0ea703edfce427de539e978a..fc1eda768f0f416362340d3e7127db622eca2bed 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -16,7 +16,6 @@ "roleStartDate": "From", "roleEndDate": "To", "comment": "Comment", - "contact": "Contact person", "searchable": "Available in search?", "email": "E-mail", "fullName": "Full name", @@ -44,6 +43,7 @@ "bodyText": "Here you can add a new role to the same guest" }, "register": { + "noResults": "No guest matching your search found.", "registerHeading": "Register new guest", "registerText": "Please search for e-mail or phone number before registering a new guest to prevent duplicates.", "registerButtonText": "Register new guest" @@ -62,20 +62,25 @@ "role": "Guest role", "period": "Period", "ou": "Organisation", + "department": "Department", "choice": "Choices", "registerText": "Register new guest", - "waitingGuests": "Guests waiting for your confirmation", - "waitingGuestsDescription": "See the table below for guests ", + "waitingGuests": "Guests waiting for confirmation", + "waitingGuestsDescription": "Confirm your guests that <1>registered manually</1> here. Guests with FEIDE-user do not need confirmation.", "noWaitingGuests": "No waiting guests", - "activeGuests": "Your active guests", - "activeGuestsDescription": "Make changes to your existing guests", + "activeGuests": "Confirmed guests", + "activeGuestsDescription": "Make changes to guest roles.", "noActiveGuests": "No active guests", "sentInvitations": "Sent invitations", "sentInvitationsDescription": "Invitations awaiting response from guest.", "noInvitations": "No invitations", "status": "Status", - "active": "Active", - "expired": "Expired", + "statusText": { + "active": "Active", + "expired": "Expired", + "waitingForGuest": "Waiting for guest", + "waitingForSponsor": "Needs confirmation" + }, "details": "Details", "nationalIdNumber": "National ID number", "validation": { @@ -146,11 +151,21 @@ "cancelInvitationDescription": "Do you want to cancel the invitation?" }, "error": { + "error": "Error", "invitationCreationFailedHeader": "Failed to create invite", "errorStatusCode": "Status code: {{statusCode}} (<3>{{statusText}}</3>)", "genericServerErrorBody": "Message:<1>{{errorBodyText}}</1>", "contactHelp": "Contact help through the link in the footer if the problem persists.", "errorLoadOusRoleTypeHeading": "Error loading form data", - "errorLoadOusRoleType": "Could not load organizational units and/or role type from server" + "errorLoadOusRoleType": "Could not load organizational units and/or role type from server", + "unknown": "An unknown error has occurred. If the problem persists, contact support.", + "invitationDataFetchFailed": "Failed to fetch invitation data", + "guestRegistrationFailed": "Failed to register your data", + "codes": { + "invalid_invite": "Invalid invite", + "invite_expired": "Invite has expired", + "cannot_update_fields": "Failed to update data", + "update_national_id_not_allowed": "Not allowed to update verified national ID" + } } } diff --git a/frontend/public/locales/en/invite.json b/frontend/public/locales/en/invite.json index 70693d275ab5a2c20d9513f1854d1fa4e0643844..ac094ff19553d9360f9af1885bc5a40152839691 100644 --- a/frontend/public/locales/en/invite.json +++ b/frontend/public/locales/en/invite.json @@ -1,6 +1,10 @@ { - "description": "Please choose how you want to log in to complete your registration. The recommended way is to log in with either Feide or ID-porten. If that is not possible you can manually fill out the registration form with your passport number.", - "header": "Guest Registration", + "description": "Please choose how you want to log in to complete your registration. The recommended way is to log in with either Feide or ID-porten. If that is not possible you can manually fill out the registration form with your passport number or Norwegian national ID number.", + "header": "Guest registration", "login": "Log in with FEIDE", - "manual": "Registrate manually" + "manual": "Register manually", + "errors": { + "invalidToken": "The invitation link you followed is invalid.", + "expiredToken": "The invitation link you followed has expired. Contact your host to get a new link." + } } diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index 3eaa4d75f3581666a46171ee3e89d5f0d74c483e..79d565aa28768ffe6a6e95fec7709a679504f0cb 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -16,7 +16,6 @@ "roleStartDate": "Fra", "roleEndDate": "Til", "comment": "Kommentar", - "contact": "Kontaktperson", "searchable": "Synlig i søk?", "email": "E-post", "fullName": "Fullt navn", @@ -44,6 +43,7 @@ "bodyText": "Her kan du legge til en ny rolle på samme gjest" }, "register": { + "noResults": "Finner ingen gjestekontoer som matcher søket ditt.", "registerHeading": "Registrer ny gjest", "registerText": "Søk etter e-post eller mobilnummer før du registrerer ny gjest for å unngå dobbeltoppføringer.", "registerButtonText": "Registrer ny gjest" @@ -62,20 +62,25 @@ "role": "Gjesterolle", "period": "Periode", "ou": "Organisasjon", + "department": "Avdeling", "choice": "Valg", "registerText": "Registrer ny gjest", - "waitingGuests": "Dine gjester som venter på godkjenning", - "waitingGuestsDescription": "Under er en oversikt over kontoer som venter på godkjenning", + "waitingGuests": "Gjester som venter på godkjenning", + "waitingGuestsDescription": "Her godkjenner du gjester som har <1>registrert seg manuelt</1>. Gjester som har FEIDE-bruker trenger ikke godkjenning.", "noWaitingGuests": "Ingen gjester til godkjenning", - "activeGuests": "Dine aktive gjester", - "activeGuestsDescription": "Her kan du endre på eksisterende gjester", + "activeGuests": "Godkjente gjester", + "activeGuestsDescription": "Her kan du endre på gjesteroller", "noActiveGuests": "Ingen aktive gjester", "sentInvitations": "Sendte invitasjoner", "sentInvitationsDescription": "Invitasjoner som venter på at gjesten skal ferdigstille registreringen.", "noInvitations": "Ingen invitasjoner", "status": "Status", - "active": "Aktiv", - "expired": "Utgått", + "statusText": { + "active": "Aktiv", + "expired": "Utgått", + "waitingForGuest": "Venter på gjest", + "waitingForSponsor": "Trenger godkjenning" + }, "details": "Detaljer", "nationalIdNumber": "Fødselsnummer/D-nummer", "validation": { @@ -146,11 +151,21 @@ "cancelInvitationDescription": "Vil du kansellere invitasjonen?" }, "error": { + "error": "Feil", "invitationCreationFailedHeader": "Kunne ikke opprette invitasjon", "errorStatusCode": "Statuskode: {{statusCode}} (<3>{{statusText}}</3>)", "genericServerErrorBody": "Melding:<1>{{errorBodyText}}</1>", "contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer.", + "unknown": "En ukjent feil har oppstått. Om problemet vedvarer, kontakt brukerstøtte.", "errorLoadOusRoleTypeHeading": "Feil under lasting av skjemadata", - "errorLoadOusRoleType": "Kunne ikke laste organisasjons og/eller rolletype data fra server" + "errorLoadOusRoleType": "Kunne ikke laste organisasjons og/eller rolletype data fra server", + "invitationDataFetchFailed": "Klarte ikke å hente invitasjonsdata", + "guestRegistrationFailed": "Klarte ikke å registrere dataene dine", + "codes": { + "invalid_invite": "Ugyldig invitasjon", + "invite_expired": "Invitasjonen har utløpt", + "cannot_update_fields": "Klarte ikke å oppdatere dataene", + "update_national_id_not_allowed": "Ikke tillatt å endre verifisert fødselsnummer/D-nummer" + } } } diff --git a/frontend/public/locales/nb/invite.json b/frontend/public/locales/nb/invite.json index 6f6b73f0d316f226596aeaa894184030db619f91..8ad98cd8c87f22369f8d57a1f9fb94a1eb7cb5cd 100644 --- a/frontend/public/locales/nb/invite.json +++ b/frontend/public/locales/nb/invite.json @@ -2,5 +2,9 @@ "description": "Vennligst velg hvordan du vil logge inn for å fullføre registreringen. Den anbefalte metoden er å logge inn gjennom Feide eller ID-porten. Dersom det ikke er mulig kan du fylle ut registreringskjemaet manuelt med passnummer", "header": "Gjestetjenesten", "login": "Logg inn med FEIDE", - "manual": "Registrer manuelt" + "manual": "Registrer manuelt", + "errors": { + "invalidToken": "Denne invitasjonslenka er ugyldig.", + "expiredToken": "Denne invitasjonslenka er utløpt. Kontakt verten din for å få tilsendt en ny." + } } diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index 001606dfbcae78e09beab00cf77813aaec07c7ac..c49aa0b3406db333e16c365a6cb2d5bb9651ceb0 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -17,7 +17,6 @@ "roleStartDate": "Frå", "roleEndDate": "Til", "comment": "Kommentar", - "contact": "Kontaktperson", "searchable": "Synleg i søk?", "email": "E-post", "fullName": "Fullt namn", @@ -45,6 +44,7 @@ "bodyText": "Her kan du legge til en ny rolle på samme gjest" }, "register": { + "noResults": "Finner ingen gjestekontoer som matcher søket ditt.", "registerHeading": "Registrer ny gjest", "registerText": "Søk etter e-post eller mobilnummer før du registrerer ny gjest for å unngå dobbeltoppføringer.", "registerButtonText": "Registrer ny gjest" @@ -63,20 +63,25 @@ "role": "Gjesterolle", "period": "Periode", "ou": "Organisasjon", + "department": "Avdeling", "choice": "Valg", "registerText": "Registrer ny gjest", - "waitingGuests": "Dine gjester som venter på godkjenning", - "waitingGuestsDescription": "Under er en oversikt over kontoer som venter på godkjenning", + "waitingGuests": "Gjester som venter på godkjenning", + "waitingGuestsDescription": "Her godkjenner du gjester som har <1>registrert seg manuelt</1>. Gjester som har FEIDE-bruker trenger ikke godkjenning.", "noWaitingGuests": "Ingen gjester til godkjenning", - "activeGuests": "Dine aktive gjester", - "activeGuestsDescription": "Her kan du endre på eksisterende gjester", + "activeGuests": "Godkjente gjester", + "activeGuestsDescription": "Her kan du endre på gjesteroller.", "noActiveGuests": "Ingen aktive gjester", "sentInvitations": "Sendte invitasjonar", "sentInvitationsDescription": "Invitasjonar som venter på at gjesten skal ferdigstille registreringa.", "noInvitations": "Ingen invitasjonar", "status": "Status", - "active": "Aktiv", - "expired": "Utgått", + "statusText": { + "active": "Aktiv", + "expired": "Utgått", + "waitingForGuest": "Venter på gjest", + "waitingForSponsor": "Trenger godkjenning" + }, "details": "Detaljer", "nationalIdNumber": "Fødselsnummer/D-nummer", "validation": { @@ -147,11 +152,21 @@ "cancelInvitationDescription": "Vil du kansellere invitasjonen?" }, "error": { + "error": "Feil", "invitationCreationFailedHeader": "Kunne ikkje opprette invitasjon", "errorStatusCode": "Statuskode: {{statusCode}} (<3>{{statusText}}</3>)", "genericServerErrorBody": "Melding:<1>{{errorBodyText}}</1>", "contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer.", + "unknown": "Ein uventa feil oppstod. Om problemet varer ved, kontakt brukarstøtte.",, "errorLoadOusRoleTypeHeading": "Feil under lasting av skjemadata", - "errorLoadOusRoleType": "Kunne ikkje laste organisasjons og/eller rolletype data frå server" + "errorLoadOusRoleType": "Kunne ikkje laste organisasjons og/eller rolletype data frå server", + "invitationDataFetchFailed": "Klarte ikkje å hente invitasjonsdata", + "guestRegistrationFailed": "Klarte ikkje å registrere dataene dine", + "codes": { + "invalid_invite": "Ugyldig invitasjon", + "invite_expired": "Invitasjonen har utløpe", + "cannot_update_fields": "Klarte ikkje å oppdatere dataene", + "update_national_id_not_allowed": "Ikkje tillete å endre verifisert fødselsnummer/D-nummer" + } } } diff --git a/frontend/public/locales/nn/invite.json b/frontend/public/locales/nn/invite.json index abb2afaf0747f645944c94525cc49b28015e87bf..155b826bcf3b540b0a5bfb81ddfeb0d7c878bc6f 100644 --- a/frontend/public/locales/nn/invite.json +++ b/frontend/public/locales/nn/invite.json @@ -2,5 +2,9 @@ "description": "Ver venleg og vel korleis du vil logge inn for å fullføre registreringa. Den anbefalte metoden er å logge inn gjennom Feide eller ID-porten. Dersom det ikkje er mogeleg kan du fylle ut registreringskjemaet manuelt med passnummer", "header": "Gjestetjenesten", "login": "Logg inn med FEIDE", - "manual": "Registrer manuelt" + "manual": "Registrer manuelt", + "errors": { + "invalidToken": "Denne invitasjonslenka er ugyldig.", + "expiredToken": "Denne invitasjonslenka har utløpe. Kontakt verten din for å få tilsendt ei ny." + } } diff --git a/frontend/src/components/debug/index.tsx b/frontend/src/components/debug/index.tsx index aca2b8bcbe8b9f8fdccb4a23572584ab6131a899..e2bc7339b61b9f995cc5534f927a35990c956853 100644 --- a/frontend/src/components/debug/index.tsx +++ b/frontend/src/components/debug/index.tsx @@ -4,16 +4,7 @@ import { useTranslation } from 'react-i18next' import CheckIcon from '@mui/icons-material/Check' import ClearIcon from '@mui/icons-material/Clear' -import { - Box, - Button, - Table, - TableBody, - TableRow, - TableCell, - Stack, - Divider, -} from '@mui/material' +import { Box, Table, TableBody, TableRow, TableCell } from '@mui/material' import { appInst, appTimezone, appVersion } from 'appConfig' import { Link } from 'react-router-dom' @@ -26,8 +17,6 @@ export const Debug = () => { const [apiHealth, setApiHealth] = useState('not yet') const [didContactApi, setDidContactApi] = useState(false) const { i18n } = useTranslation(['common']) - const [csrf, setCsrf] = useState<String | null>(null) - const [username, setUsername] = useState(undefined) const { user } = useUserContext() if (!didContactApi) { @@ -47,87 +36,6 @@ export const Debug = () => { }) } - const getCSRF = () => { - fetch('/api/ui/v1/csrf/', { - credentials: 'same-origin', - }) - .then((res) => { - const csrfToken = res.headers.get('X-CSRFToken') - setCsrf(csrfToken) - console.log(csrfToken) - }) - .catch((err) => { - console.log(err) - }) - } - - const getSession = () => { - fetch('/api/ui/v1/session/?format=json', { - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'same-origin', - }) - .then((res) => res.json()) - .then((data) => { - console.log(data) - getCSRF() - }) - .catch((err) => { - console.log(err) - }) - } - - const testMail = () => { - fetch('/api/ui/v1/testmail/', { - credentials: 'same-origin', - }) - .then((data) => { - console.log(data) - }) - .catch((err) => { - console.log(err) - }) - } - - const whoami = () => { - fetch('/api/ui/v1/whoami/', { - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'same-origin', - }) - .then((res) => res.json()) - .then((data) => { - setUsername(data.username) - console.log(`You are logged in as: ${data.username}`) - }) - .catch((err) => { - console.log(err) - }) - } - - function isResponseOk(response: any) { - if (response.status >= 200 && response.status <= 299) { - return response.json() - } - throw Error(response.statusText) - } - - const logout = () => { - fetch('/api/ui/v1/logout/', { - credentials: 'same-origin', - }) - .then(isResponseOk) - .then((data) => { - console.log(data) - getCSRF() - }) - .catch((err) => { - console.log(err) - }) - } - const d = [ ['NODE_ENV', process.env.NODE_ENV], ['Version', appVersion], @@ -137,8 +45,6 @@ export const Debug = () => { ['Institution', appInst], ['API reachable?', apiHealth === 'yes' ? <Yes /> : apiHealth], ['Authenticated?', user.auth ? <Yes /> : <No />], - ['Username', username], - ['CSRF', csrf], ] return ( <Box> @@ -163,25 +69,6 @@ export const Debug = () => { </ul> </p> <h3>Debug</h3> - - <Stack - direction="row" - spacing={1} - divider={<Divider orientation="vertical" />} - > - <Button type="button" onClick={() => getSession()}> - AM I AUTHENTICATED? - </Button> - <Button type="button" onClick={() => whoami()}> - WHO AM I? - </Button> - <Button type="button" onClick={() => logout()}> - LOGOUT - </Button> - <Button type="button" onClick={() => testMail()}> - SEND TEST MAIL - </Button> - </Stack> <Box sx={{ maxWidth: '30rem' }}> <Table> <TableBody> diff --git a/frontend/src/contexts/featureContext.ts b/frontend/src/contexts/featureContext.ts index 0fd76a4e800c9e5483994834b9ad0a06cb9717f9..238f2bc3a99393edc4e6d7f4a113d4caba9bc6ab 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 + // Should the contact at unit field be shown for the guest when he registers his information? + displayContactAtUnitGuestInput: boolean } export const FeatureContext = createContext<IFeatureContext>({ displayContactAtUnit: true, displayComment: true, + displayContactAtUnitGuestInput: true, }) export const useFeatureContext = () => useContext(FeatureContext) diff --git a/frontend/src/hooks/useRoleTypes/index.tsx b/frontend/src/hooks/useRoleTypes/index.tsx index 2c8eec3d5019156c0debcbae4d7ee1af7d825fa0..a18671307489b2767ef28ba86cac8e42e3f1bca7 100644 --- a/frontend/src/hooks/useRoleTypes/index.tsx +++ b/frontend/src/hooks/useRoleTypes/index.tsx @@ -12,7 +12,7 @@ function useRoleTypes(): RoleTypeData[] { const [roleTypes, setRoleTypes] = useState<RoleTypeData[]>([]) async function fetchRoleTypes() { - fetch(`/api/ui/v1/roletypes`) + fetch(`/api/ui/v1/roletypes/`) .then((data) => data.text()) .then((result) => { // The response is a JSON-array diff --git a/frontend/src/providers/featureProvider.tsx b/frontend/src/providers/featureProvider.tsx index 962e46aa14d866dd640a5ded31bce38c039753c0..6f5e724225ed67e2ced996aa9c7865d7f9db33a6 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, + displayContactAtUnitGuestInput: false, + } break case 'uio': default: - features = { displayContactAtUnit: true, displayComment: true } + features = { + displayContactAtUnit: true, + displayComment: true, + displayContactAtUnitGuestInput: true, + } break } diff --git a/frontend/src/routes/components/sponsorGuestButtons.tsx b/frontend/src/routes/components/sponsorGuestButtons.tsx index 021703014090bae566c63ea0be96f2298712f2fd..b713ff98ce6fb280fc0cb257286d58557ab69d38 100644 --- a/frontend/src/routes/components/sponsorGuestButtons.tsx +++ b/frontend/src/routes/components/sponsorGuestButtons.tsx @@ -1,5 +1,5 @@ import PersonIcon from '@mui/icons-material/Person' -import { Box, IconButton, Theme } from '@mui/material' +import { Box, IconButton, styled, Theme } from '@mui/material' import PersonAddIcon from '@mui/icons-material/PersonAdd' import React from 'react' import { useTranslation } from 'react-i18next' @@ -10,6 +10,13 @@ interface SponsorGuestButtonsProps { registerNewGuestActive?: boolean } +const StyledIconButton = styled(IconButton)({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + fontSize: '22px', +}) + export default function SponsorGuestButtons(props: SponsorGuestButtonsProps) { const { yourGuestsActive, registerNewGuestActive } = props const { t } = useTranslation(['common']) @@ -30,22 +37,20 @@ export default function SponsorGuestButtons(props: SponsorGuestButtonsProps) { flexDirection: 'row', justifyContent: 'space-evenly', marginBottom: '2rem', + fontSize: '22px', }} > - <IconButton + <StyledIconButton onClick={goToOverview} sx={{ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - typography: 'caption', + color: () => (yourGuestsActive ? 'primary.main' : ''), textDecorationLine: () => (yourGuestsActive ? 'underline' : ''), }} > <PersonIcon - fontSize="large" sx={{ - borderRadius: '2rem', + fontSize: '80px', + borderRadius: '4rem', borderStyle: 'solid', borderColor: (theme: Theme) => yourGuestsActive @@ -59,22 +64,19 @@ export default function SponsorGuestButtons(props: SponsorGuestButtonsProps) { }} /> {t('yourGuests')} - </IconButton> + </StyledIconButton> - <IconButton + <StyledIconButton onClick={goToRegister} sx={{ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - typography: 'caption', + color: () => (registerNewGuestActive ? 'primary.main' : ''), textDecorationLine: () => (registerNewGuestActive ? 'underline' : ''), }} > <PersonAddIcon - fontSize="large" sx={{ - borderRadius: '2rem', + fontSize: '80px', + borderRadius: '4rem', borderStyle: 'solid', borderColor: (theme: Theme) => registerNewGuestActive @@ -88,7 +90,7 @@ export default function SponsorGuestButtons(props: SponsorGuestButtonsProps) { }} /> {t('registerNewGuest')} - </IconButton> + </StyledIconButton> </Box> ) } diff --git a/frontend/src/routes/guest/register/guestDataForm.ts b/frontend/src/routes/guest/register/guestDataForm.ts index c1d35dcd19de659ccc018a0166797cf18119ffa4..5c86b6000fccd05c06dd091b5198ef23c3a99c6a 100644 --- a/frontend/src/routes/guest/register/guestDataForm.ts +++ b/frontend/src/routes/guest/register/guestDataForm.ts @@ -14,7 +14,7 @@ export type GuestInviteInformation = { role_name_nb: string role_start: string role_end: string - comment?: string + contact_person_unit?: string feide_id?: string email?: string diff --git a/frontend/src/routes/guest/register/index.test.tsx b/frontend/src/routes/guest/register/index.test.tsx index b8a9c0df406d959b8375c6acdceb89c2eb3b3a9a..0ac01aee3ccd557ed5bfce7a30b38b62ffb1ccde 100644 --- a/frontend/src/routes/guest/register/index.test.tsx +++ b/frontend/src/routes/guest/register/index.test.tsx @@ -25,7 +25,6 @@ const testData = { start: '2021-08-10', end: '2021-08-16', contact_person_unit: 'Test contact person', - comments: 'Test comment', }, meta: { session_type: 'invite', @@ -66,5 +65,7 @@ test('Field showing values correctly', async () => { await screen.findByDisplayValue( `${testData.role.start} - ${testData.role.end}` ) - await screen.findByDisplayValue(testData.role.comments) + + // For the default setup the contact person at unit field should be showing + await screen.findByDisplayValue(testData.role.contact_person_unit) }) diff --git a/frontend/src/routes/guest/register/index.tsx b/frontend/src/routes/guest/register/index.tsx index c595bac7361717600f2670694d96eec2852f3782..3b8bd5b022442fcd09f8d6927bd371aa94f9d97f 100644 --- a/frontend/src/routes/guest/register/index.tsx +++ b/frontend/src/routes/guest/register/index.tsx @@ -21,12 +21,9 @@ import AuthenticationMethod from './authenticationMethod' import GuestRegisterStep from './steps/register' import GuestConsentStep from './steps/consent' import GuestSuccessStep from './steps/success' - -enum SubmitState { - NotSubmitted, - Submitted, - SubmittedError, -} +import ServerErrorReport, { + ErrorReportProps, +} from '../../../components/errorReport' enum Step { RegisterStep, @@ -56,7 +53,7 @@ type InvitationData = { role_name_en: string start: string end: string - comments: string + contact_person_unit: string } meta: { session_type: string @@ -72,12 +69,6 @@ export default function GuestRegister() { const guestRegisterRef = useRef<GuestRegisterCallableMethods>(null) const guestConsentRef = useRef<GuestRegisterCallableMethods>(null) - - // TODO On submit successful the user should be directed to some page telling - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [submitState, setSubmitState] = useState<SubmitState>( - SubmitState.NotSubmitted - ) const [activeStep, setActiveStep] = useState(0) const [initialGuestData, setInitialGuestData] = @@ -87,12 +78,39 @@ export default function GuestRegister() { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [guestConsentData, setGuestConsentData] = useState<GuestConsentData | null>(null) + const [fetchInvitationDataError, setFetchInvitationDataError] = + useState<ErrorReportProps | null>(null) + const [submitGuestDataError, setSubmitGuestDataError] = + useState<ErrorReportProps | null>(null) const fetchInvitationData = async () => { const response = await fetch('/api/ui/v1/invited/', fetchJsonOpts()) if (!response.ok) { + try { + // Expect that the error will contain some JSON-data in the body + const errorData = await response.json() + setFetchInvitationDataError({ + errorHeading: t('error.invitationDataFetchFailed'), + statusCode: response.status, + statusText: response.statusText, + errorBodyText: t(`error.codes.${errorData?.code}`), + }) + } catch (e: unknown) { + console.error(e) + + // Probably some unknown data in the body, create an error message + // using the rest of the information in the response + setFetchInvitationDataError({ + errorHeading: t('error.invitationDataFetchFailed'), + statusCode: response.status, + statusText: response.statusText, + errorBodyText: undefined, + }) + } + return } + setFetchInvitationDataError(null) const data: InvitationData = await response.json() const authenticationMethod = @@ -149,7 +167,7 @@ export default function GuestRegister() { role_name_nb: data.role.role_name_nb ?? '', role_start: data.role.start ?? '', role_end: data.role.end ?? '', - comment: data.role.comments ?? '', + contact_person_unit: data.role.contact_person_unit ?? '', authentication_method: authenticationMethod, }) @@ -192,6 +210,8 @@ export default function GuestRegister() { const handleBack = () => { if (activeStep === Step.ConsentStep) { setActiveStep(Step.RegisterStep) + // Clear error if any + setSubmitGuestDataError(null) } } @@ -257,15 +277,43 @@ export default function GuestRegister() { fetch('/api/ui/v1/invited/', submitJsonOpts('POST', payload)) .then((response) => { if (response.ok) { - setSubmitState(SubmitState.Submitted) setActiveStep(Step.SuccessStep) + setSubmitGuestDataError(null) } else { - setSubmitState(SubmitState.SubmittedError) - console.error(`Server responded with status: ${response.status}`) + // Expect that the error will contain JSON-data in the body + // and that there will be a code field there + response + .json() + .then((errorData) => { + setSubmitGuestDataError({ + errorHeading: t('error.guestRegistrationFailed'), + statusCode: response.status, + statusText: response.statusText, + errorBodyText: t(`error.codes.${errorData?.code}`), + }) + }) + .catch((error) => { + console.error(error) + + // Probably some unknown data in the body, create an error message + // using the rest of the information in the response + setSubmitGuestDataError({ + errorHeading: t('error.guestRegistrationFailed'), + statusCode: response.status, + statusText: response.statusText, + errorBodyText: undefined, + }) + }) } }) .catch((error) => { - setSubmitState(SubmitState.SubmittedError) + // Something went wrong before/during the backend was called + setSubmitGuestDataError({ + errorHeading: t('error.guestRegistrationFailed'), + statusCode: undefined, + statusText: undefined, + errorBodyText: undefined, + }) console.error(error) }) } @@ -372,6 +420,24 @@ export default function GuestRegister() { </Button> )} </Box> + + {fetchInvitationDataError !== null && ( + <ServerErrorReport + errorHeading={fetchInvitationDataError?.errorHeading} + statusCode={fetchInvitationDataError?.statusCode} + statusText={fetchInvitationDataError?.statusText} + errorBodyText={fetchInvitationDataError?.errorBodyText} + /> + )} + + {submitGuestDataError !== null && ( + <ServerErrorReport + errorHeading={submitGuestDataError?.errorHeading} + statusCode={submitGuestDataError?.statusCode} + statusText={submitGuestDataError?.statusText} + errorBodyText={submitGuestDataError?.errorBodyText} + /> + )} </Page> ) } diff --git a/frontend/src/routes/guest/register/steps/register.tsx b/frontend/src/routes/guest/register/steps/register.tsx index 9cc70c712169fe2e812b4b3caaef7856ede06bfb..3b4fbb35a67232f2947783647d2a59c6f2b8225f 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 } @@ -57,6 +60,7 @@ const GuestRegisterStep = forwardRef( string | undefined >(undefined) const [idErrorState, setIdErrorState] = useState<string>('') + const { displayContactAtUnitGuestInput } = useContext(FeatureContext) console.log('register step registerData', registerData) @@ -528,14 +532,14 @@ const GuestRegisterStep = forwardRef( disabled /> - <TextField - id="comment" - label={t('input.comment')} - multiline - rows={5} - value={initialGuestData.comment} - disabled - /> + {displayContactAtUnitGuestInput && ( + <TextField + id="contactPersonUnit" + label={t('input.contactPersonUnit')} + value={initialGuestData.contact_person_unit} + disabled + /> + )} </Stack> </form> </Box> diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index e273442a88500c9057310d7a663a4b068b2be86d..1276166a6855b29848e62951470931c8caae4798 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -5,24 +5,24 @@ import { styled } from '@mui/system' import { CssBaseline } from '@mui/material' import fetchIntercept from 'fetch-intercept' +import { registerLocale } from 'i18n-iso-countries' +import i18n_iso_countries_en from 'i18n-iso-countries/langs/en.json' +import i18n_iso_countries_nb from 'i18n-iso-countries/langs/nb.json' +import i18n_iso_countries_nn from 'i18n-iso-countries/langs/nn.json' + import { useUserContext } from 'contexts' import { getCookie, deleteCookie } from 'utils' +import GuestRegister from 'routes/guest/register' import Sponsor from 'routes/sponsor' import Register from 'routes/sponsor/register' import FrontPage from 'routes/frontpage' import Invite from 'routes/invite' -import InviteLink from 'routes/invitelink' -import LogoutInviteSession from 'routes/invitelink/logout' +import LogoutInviteSession from 'routes/invite/logout' import Footer from 'routes/components/footer' import Header from 'routes/components/header' import NotFound from 'routes/components/notFound' import ProtectedRoute from 'components/protectedRoute' -import { registerLocale } from 'i18n-iso-countries' -import i18n_iso_countries_en from 'i18n-iso-countries/langs/en.json' -import i18n_iso_countries_nb from 'i18n-iso-countries/langs/nb.json' -import i18n_iso_countries_nn from 'i18n-iso-countries/langs/nn.json' -import GuestRegister from './guest/register' const AppWrapper = styled('div')({ display: 'flex', @@ -77,7 +77,6 @@ export default function App() { <ProtectedRoute path="/register"> <Register /> </ProtectedRoute> - <Route path="/invitelink/" component={InviteLink} /> <Route path="/invite/logout" component={LogoutInviteSession} /> <Route path="/invite/" component={Invite} /> <Route path="/guestregister" component={GuestRegister} /> diff --git a/frontend/src/routes/invite/index.tsx b/frontend/src/routes/invite/index.tsx index ba43e2d7c2a31266e2f209d6a838d44099b22f48..14acbc64312086b838447bf554ba76f48e48329e 100644 --- a/frontend/src/routes/invite/index.tsx +++ b/frontend/src/routes/invite/index.tsx @@ -1,25 +1,27 @@ +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Page from 'components/page' +import Loading from 'components/loading' import { styled } from '@mui/material/styles' +import { useUserContext } from 'contexts' import { HrefButton } from 'components/button' import { HrefLineButton } from 'components/button/linebutton' +import { submitJsonOpts } from 'utils' const FlexDiv = styled('div')(() => ({ display: 'flex', gap: '0.5rem', })) -function Invite() { +function ChooseRegistrationMethod() { const { t } = useTranslation(['invite']) return ( <Page> <h1>{t('header')}</h1> - <p> - {t('description')} - </p> + <p>{t('description')}</p> <FlexDiv> <HrefButton to="/oidc/authenticate/">{t('login')}</HrefButton> <HrefLineButton to="/guestregister/">{t('manual')}</HrefLineButton> @@ -28,4 +30,125 @@ function Invite() { ) } +interface ShowFeedbackProps { + title: string + description: string +} + +function ShowFeedback(props: ShowFeedbackProps) { + const { title, description } = props + + return ( + <Page> + <h1>{title}</h1> + <p>{description}</p> + </Page> + ) +} + +function Invite() { + const { t } = useTranslation(['invite']) + const { user, fetchUserInfo } = useUserContext() + const [inviteToken, setInviteToken] = useState('') + const [tokenChecked, setTokenChecked] = useState(false) + const [isCheckingToken, setIsCheckingToken] = useState(false) + const [tokenOk, setTokenOk] = useState(false) + const [checkError, setCheckError] = useState('') + + async function checkToken(token: string) { + try { + const response = await fetch( + '/api/ui/v1/invitecheck/', + submitJsonOpts('POST', { invite_token: token }) + ) + if (response.status === 200) { + setTokenOk(true) + return + } + const data = await response.json() + + if ('code' in data) { + setCheckError(data.code) + } else { + setCheckError('unknown') + } + } catch (error) { + console.error(error) + setCheckError('unknown') + } finally { + setTokenChecked(true) + setIsCheckingToken(false) + } + } + + console.log({ inviteToken, isCheckingToken, tokenChecked, tokenOk, user }) + + useEffect(() => { + setIsCheckingToken(true) + // This may seem unecessary, but race conditions have been + // observed where the userinfo endpoint is called too fast + // and no invite_id is found in the server-side session + setTimeout(fetchUserInfo, 100) + }, [setTokenOk]) + + if (user.auth) { + return <ChooseRegistrationMethod /> + } + + if (isCheckingToken || (tokenOk && !user.auth)) { + return <Loading /> + } + + if (inviteToken !== '' && !tokenChecked) { + checkToken(inviteToken) + return <Loading /> + } + + if (checkError !== '') { + if ( + checkError === 'missing_invite_token' || + checkError === 'invalid_invite_token' + ) { + // Missing or invalid token + return ( + <ShowFeedback + title={t('common:error.error')} + description={t('invite:errors.invalidToken')} + /> + ) + } + if (checkError === 'expired_invite_token') { + // Expired token + return ( + <ShowFeedback + title={t('common:error.error')} + description={t('invite:errors.expiredToken')} + /> + ) + } + // Unknown error + return ( + <ShowFeedback + title={t('common:error.error')} + description={t('common:error.unknown')} + /> + ) + } + + const providedToken = window.location.hash.slice(1).trim() + + if (!inviteToken && providedToken) { + setInviteToken(providedToken) + return <Loading /> + } + + // We'll end up here if no token was provided in the URL + return ( + <ShowFeedback + title={t('common:error.error')} + description={t('invite:errors.invalidToken')} + /> + ) +} + export default Invite diff --git a/frontend/src/routes/invitelink/logout.tsx b/frontend/src/routes/invite/logout.tsx similarity index 100% rename from frontend/src/routes/invitelink/logout.tsx rename to frontend/src/routes/invite/logout.tsx diff --git a/frontend/src/routes/invitelink/index.tsx b/frontend/src/routes/invitelink/index.tsx deleted file mode 100644 index fd8d63f056582d59de1a1774688a6d13506e8ec2..0000000000000000000000000000000000000000 --- a/frontend/src/routes/invitelink/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect } from 'react' -import { Redirect } from 'react-router-dom' -import { submitJsonOpts, setCookie } from 'utils' - -function InviteLink() { - // Fetch backend endpoint to preserve invite_id in backend session then redirect - // to generic invite page with info about feide login or manual with passport. - - const id = window.location.hash.slice(1) - - useEffect(() => { - fetch('/api/ui/v1/invitecheck/', submitJsonOpts('POST', { uuid: id })) - }, []) - setCookie('redirect', '/guestregister') - return <Redirect to="/invite" /> -} - -export default InviteLink diff --git a/frontend/src/routes/sponsor/frontpage/index.tsx b/frontend/src/routes/sponsor/frontpage/index.tsx index 1353b459c703c3edaffcbc172fbc62aa97c01028..678c03598fb145e5cdb921f43f6f8f30186a23e0 100644 --- a/frontend/src/routes/sponsor/frontpage/index.tsx +++ b/frontend/src/routes/sponsor/frontpage/index.tsx @@ -12,13 +12,15 @@ import { AccordionDetails, Button, } from '@mui/material' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive' +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward' +import { styled } from '@mui/system' import Page from 'components/page' -import { useTranslation } from 'react-i18next' +import { useTranslation, Trans } from 'react-i18next' import { Link } from 'react-router-dom' import { Guest, Role } from 'interfaces' -import { isBefore, format } from 'date-fns' +import { isBefore } from 'date-fns' import SponsorGuestButtons from '../../components/sponsorGuestButtons' @@ -29,40 +31,118 @@ interface GuestProps { interface PersonLineProps { person: Guest role: Role - showStatusColumn?: boolean } -const PersonLine = ({ person, role, showStatusColumn }: PersonLineProps) => { +interface StatusProps { + status: string +} + +interface GuestTableProps { + guests: Guest[] + emptyText: string +} + +interface FrontPageProps { + guests: Guest[] +} + +const StyledTableRow = styled(TableRow)(({ theme }) => ({ + borderTop: '0', + borderLeft: '0', + borderRight: '0', + borderBottom: '2px solid', + borderColor: theme.palette.primary.main, + borderRadius: '0', +})) + +const StyledAccordion = styled(Accordion)({ + borderStyle: 'none', + boxShadow: 'none', +}) + +const StyledAccordionSummary = styled(AccordionSummary)({ + borderStyle: 'solid', + borderColor: 'black', + borderWidth: '2px', + borderRadius: '1%', +}) + +const StyledTableHeadCell = styled(TableCell)({ + fontWeight: 'bold', +}) + +const StyledTableHead = styled(TableHead)(({ theme }) => ({ + borderTop: '0', + borderLeft: '0', + borderRight: '0', + borderBottom: '3px solid', + borderColor: theme.palette.secondary.main, + borderRadius: '0', +})) + +const Status = ({ status }: StatusProps) => { + const { t } = useTranslation('common') + switch (status) { + case 'active': + return ( + <TableCell sx={{ color: 'success.main' }} align="left"> + {t('statusText.active')} + </TableCell> + ) + case 'expired': + return ( + <TableCell sx={{ color: 'error.main' }} align="left"> + {t('statusText.expired')} + </TableCell> + ) + case 'waitingForGuest': + return ( + <TableCell sx={{ color: 'blue' }} align="left"> + {t('statusText.waitingForGuest')} + </TableCell> + ) + case 'waitingForSponsor': + return ( + <TableCell sx={{ color: 'blue' }} align="left"> + {t('statusText.waitingForSponsor')} + </TableCell> + ) + default: + return ( + <TableCell sx={{ color: 'error.main' }} align="left"> + {t('statusText.waiting')} + </TableCell> + ) + } +} + +const PersonLine = ({ person, role }: PersonLineProps) => { const [t, i18n] = useTranslation(['common']) const today = new Date() today.setHours(0, 0, 0, 0) + let status = '' + + if (!person.registered) { + status = 'waitingForGuest' + } else if (person.registered && !person.verified) { + status = 'waitingForSponsor' + } else if (person.registered && person.verified) { + if (!isBefore(role.end_date, today)) { + status = 'active' + } else { + status = 'expired' + } + } + return ( - <TableRow - key={`${person.first} ${person.last}`} - sx={{ '&:last-child td, &:last-child th': { border: 0 } }} - > + <StyledTableRow key={`${person.first} ${person.last}`}> <TableCell component="th" scope="row"> {`${person.first} ${person.last}`} </TableCell> <TableCell align="left"> {i18n.language === 'en' ? role.name_en : role.name_nb} </TableCell> - - {showStatusColumn && - (!isBefore(role.end_date, today) ? ( - <TableCell sx={{ color: 'green' }} align="left"> - {t('common:active')} - </TableCell> - ) : ( - <TableCell sx={{ color: 'red' }} align="left"> - {t('common:expired')} - </TableCell> - ))} - - <TableCell align="left"> - {role.start_date ? format(role.start_date, 'yyyy-MM-dd') : null} -{' '} - {format(role.end_date, 'yyyy-MM-dd')} - </TableCell> + <Status status={status} /> <TableCell align="left"> {i18n.language === 'en' ? role.ou_en : role.ou_nb} </TableCell> @@ -76,15 +156,47 @@ const PersonLine = ({ person, role, showStatusColumn }: PersonLineProps) => { {t('common:details')} </Button> </TableCell> - </TableRow> + </StyledTableRow> ) } -PersonLine.defaultProps = { - showStatusColumn: false, +const GuestTable = ({ guests, emptyText }: GuestTableProps) => { + const { t } = useTranslation('common') + + return ( + <TableContainer + component={Paper} + sx={{ boxShadow: 'none', borderRadius: '0px' }} + > + <Table sx={{ minWidth: 650 }} aria-label="simple table"> + <StyledTableHead> + <TableRow> + <StyledTableHeadCell>{t('common:name')}</StyledTableHeadCell> + <StyledTableHeadCell>{t('common:role')}</StyledTableHeadCell> + <StyledTableHeadCell>{t('common:status')}</StyledTableHeadCell> + <StyledTableHeadCell>{t('common:department')}</StyledTableHeadCell> + <StyledTableHeadCell /> + </TableRow> + </StyledTableHead> + <TableBody> + {guests.length > 0 ? ( + guests.map((person) => + person.roles.map((role) => ( + <PersonLine role={role} person={person} /> + )) + ) + ) : ( + <TableRow> + <TableCell> {emptyText}</TableCell> + </TableRow> + )} + </TableBody> + </Table> + </TableContainer> + ) } -const WaitingForGuestRegistration = ({ persons }: GuestProps) => { +const InvitedGuests = ({ persons }: GuestProps) => { const [activeExpanded, setActiveExpanded] = useState(false) // Show guests that have not responded to the invite yet @@ -95,53 +207,20 @@ const WaitingForGuestRegistration = ({ persons }: GuestProps) => { } const [t] = useTranslation(['common']) return ( - <Accordion + <StyledAccordion expanded={activeExpanded} onChange={() => { setActiveExpanded(!activeExpanded) }} > - <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <StyledAccordionSummary expandIcon={<ArrowUpwardIcon color="primary" />}> <h2>{t('common:sentInvitations')}</h2> - </AccordionSummary> + </StyledAccordionSummary> <AccordionDetails> <p>{t('common:sentInvitationsDescription')}</p> - <TableContainer component={Paper}> - <Table sx={{ minWidth: 650 }} aria-label="simple table"> - <TableHead sx={{ backgroundColor: 'secondary.light' }}> - <TableRow> - <TableCell>{t('common:name')}</TableCell> - <TableCell align="left">{t('common:role')}</TableCell> - <TableCell align="left">{t('common:period')}</TableCell> - <TableCell align="left">{t('common:ou')}</TableCell> - <TableCell align="left">{t('common:choice')}</TableCell> - </TableRow> - </TableHead> - <TableBody> - {guests.length > 0 ? ( - guests.map((person) => - person.roles ? ( - person.roles.map((role) => ( - <PersonLine role={role} person={person} /> - )) - ) : ( - <></> - ) - ) - ) : ( - <></> - )} - - <TableRow> - <TableCell> - {guests.length > 0 ? '' : t('common:noActiveGuests')} - </TableCell> - </TableRow> - </TableBody> - </Table> - </TableContainer> + <GuestTable guests={guests} emptyText={t('common:noInvitations')} /> </AccordionDetails> - </Accordion> + </StyledAccordion> ) } @@ -155,60 +234,20 @@ const ActiveGuests = ({ persons }: GuestProps) => { } const [t] = useTranslation(['common']) return ( - <Accordion + <StyledAccordion expanded={activeExpanded} onChange={() => { setActiveExpanded(!activeExpanded) }} > - <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <StyledAccordionSummary expandIcon={<ArrowUpwardIcon color="primary" />}> <h2>{t('common:activeGuests')}</h2> - </AccordionSummary> + </StyledAccordionSummary> <AccordionDetails> <p>{t('common:activeGuestsDescription')}</p> - <TableContainer component={Paper}> - <Table sx={{ minWidth: 650 }} aria-label="simple table"> - <TableHead sx={{ backgroundColor: 'secondary.light' }}> - <TableRow> - <TableCell>{t('common:name')}</TableCell> - <TableCell align="left">{t('common:role')}</TableCell> - - <TableCell align="left">{t('common:status')}</TableCell> - - <TableCell align="left">{t('common:period')}</TableCell> - <TableCell align="left">{t('common:ou')}</TableCell> - <TableCell align="left">{t('common:choice')}</TableCell> - </TableRow> - </TableHead> - <TableBody> - {guests.length > 0 ? ( - guests.map((person) => - person.roles ? ( - person.roles.map((role) => ( - <PersonLine - role={role} - person={person} - showStatusColumn - /> - )) - ) : ( - <></> - ) - ) - ) : ( - <></> - )} - - <TableRow> - <TableCell> - {guests.length > 0 ? '' : t('common:noActiveGuests')} - </TableCell> - </TableRow> - </TableBody> - </Table> - </TableContainer> + <GuestTable guests={guests} emptyText={t('common:noActiveGuests')} /> </AccordionDetails> - </Accordion> + </StyledAccordion> ) } @@ -223,67 +262,43 @@ const WaitingGuests = ({ persons }: GuestProps) => { const [t] = useTranslation(['common']) return ( - <Accordion + <StyledAccordion expanded={waitingExpanded} onChange={() => { setWaitingExpanded(!waitingExpanded) }} - sx={{ border: 'none' }} > - <AccordionSummary expandIcon={<ExpandMoreIcon />}> - <h2>{t('common:waitingGuests')}</h2> - </AccordionSummary> + <StyledAccordionSummary expandIcon={<ArrowUpwardIcon color="primary" />}> + <h2> + {t('common:waitingGuests')}{' '} + {guests.length > 0 && ( + <NotificationsActiveIcon + sx={{ color: 'error.main', fontSize: '26px' }} + /> + )} + </h2> + </StyledAccordionSummary> <AccordionDetails> - <p>{t('common:waitingGuestsDescription')}</p> - - <TableContainer component={Paper}> - <Table sx={{ minWidth: 650 }} aria-label="simple table"> - <TableHead sx={{ backgroundColor: 'secondary.light' }}> - <TableRow> - <TableCell>{t('common:name')}</TableCell> - <TableCell align="left">{t('common:role')}</TableCell> - <TableCell align="left">{t('common:period')}</TableCell> - <TableCell align="left">{t('common:ou')}</TableCell> - <TableCell align="left">{t('common:choice')}</TableCell> - </TableRow> - </TableHead> - <TableBody> - {guests.length > 0 ? ( - guests.map((person) => - person.roles ? ( - person.roles.map((role) => ( - <PersonLine role={role} person={person} /> - )) - ) : ( - <></> - ) - ) - ) : ( - <></> - )} - <TableRow> - <TableCell> - {guests.length > 0 ? '' : t('common:noWaitingGuests')} - </TableCell> - </TableRow> - </TableBody> - </Table> - </TableContainer> + <p> + <Trans i18nKey="common:waitingGuestsDescription"> + Her godkjenner du gjester som har <b>registrert seg manuelt</b>. + Gjester som har FEIDE-bruker trenger ikke godkjenning. + </Trans> + </p> + <GuestTable guests={guests} emptyText={t('common:noWaitingGuests')} /> </AccordionDetails> - </Accordion> + </StyledAccordion> ) } -interface FrontPageProps { - guests: Guest[] -} - function FrontPage({ guests }: FrontPageProps) { return ( <Page> <SponsorGuestButtons yourGuestsActive /> - <WaitingForGuestRegistration persons={guests} /> + <InvitedGuests persons={guests} /> + <br /> <WaitingGuests persons={guests} /> + <br /> <ActiveGuests persons={guests} /> </Page> ) diff --git a/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx b/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx index 66112b9c0b155e2022323a20b0e131b2a7f5bc16..82c67530aa227461b129c9dcdbe67c30e43aa555 100644 --- a/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx +++ b/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx @@ -138,10 +138,8 @@ export default function GuestRoleInfo({ guest }: GuestRoleInfoProps) { render={({ field: { onChange, value } }) => ( <DatePicker mask="____-__-__" - disabled={roleInfo.start_date <= today} label={t('input.roleStartDate')} value={value} - minDate={today} maxDate={todayPlusMaxDays} inputFormat="yyyy-MM-dd" onChange={onChange} @@ -159,8 +157,6 @@ export default function GuestRoleInfo({ guest }: GuestRoleInfoProps) { <DatePicker mask="____-__-__" label={t('input.roleEndDate')} - disabled={roleInfo.end_date < today} - minDate={today} maxDate={todayPlusMaxDays} value={value} inputFormat="yyyy-MM-dd" diff --git a/frontend/src/routes/sponsor/guest/newGuestRole/index.tsx b/frontend/src/routes/sponsor/guest/newGuestRole/index.tsx index f438dca6830fb68c0a7c7e9d5f803918a4d7ee66..3fc2155c5d4161d56c1b49a937d1207299185ef9 100644 --- a/frontend/src/routes/sponsor/guest/newGuestRole/index.tsx +++ b/frontend/src/routes/sponsor/guest/newGuestRole/index.tsx @@ -11,6 +11,7 @@ import { TextField, SelectChangeEvent, FormControlLabel, + Box, } from '@mui/material' import Page from 'components/page' import { format } from 'date-fns' @@ -117,13 +118,14 @@ function NewGuestRole({ guest, reloadGuestInfo }: NewGuestRoleProps) { const [roleTypeChoice, setRoleTypeChoice] = useState<string>('') const [t, i18n] = useTranslation('common') const today = new Date() + const [maxDate, setMaxDate] = useState(today) - const todayPlusMaxDays = () => { - if (roleTypeChoice) { - const role = roleTypes.filter( - (rt) => rt.id.toString() === roleTypeChoice.toString() - )[0] - return addDays(role.max_days)(today) + const todayPlusMaxDays = (roleTypeId?: number) => { + if (roleTypeId) { + const role = roleTypes.find((rt) => rt.id === roleTypeId) + if (role !== undefined) { + return addDays(role.max_days)(today) + } } return addDays(0)(today) } @@ -138,6 +140,7 @@ function NewGuestRole({ guest, reloadGuestInfo }: NewGuestRoleProps) { const handleRoleTypeChange = (event: SelectChangeEvent) => { setValue('type', event.target.value) setRoleTypeChoice(event.target.value) + setMaxDate(todayPlusMaxDays(Number(event.target.value))) } const handleOuChange = (event: SelectChangeEvent) => { if (event.target.value) { @@ -193,18 +196,21 @@ function NewGuestRole({ guest, reloadGuestInfo }: NewGuestRoleProps) { label={t('common:ou')} onChange={handleOuChange} > - {ous.length > 0 ? ( + {ous.length > 0 && ous .sort(i18n.language === 'en' ? enSort : nbSort) - .map((ou) => ouToItem(ou)) - ) : ( - <></> - )} + .map((ou) => ouToItem(ou))} </Select> </FormControl> <Controller name="start_date" control={control} + rules={{ + required: true, + validate: () => + Number(getValues('start_date')) <= + Number(getValues('end_date')), + }} defaultValue={today} render={({ field }) => ( <DatePicker @@ -212,8 +218,7 @@ function NewGuestRole({ guest, reloadGuestInfo }: NewGuestRoleProps) { label={t('input.roleStartDate')} disabled={!roleTypeChoice} value={field.value} - minDate={today} - maxDate={todayPlusMaxDays()} + maxDate={maxDate} inputFormat="yyyy-MM-dd" onChange={(value) => { field.onChange(value) @@ -222,9 +227,26 @@ function NewGuestRole({ guest, reloadGuestInfo }: NewGuestRoleProps) { /> )} /> + + {errors.start_date && errors.start_date.type === 'required' && ( + <Box sx={{ typography: 'caption', color: 'error.main' }}> + {t('validation.startDateMustBeSet')} + </Box> + )} + {errors.start_date && errors.start_date.type === 'validate' && ( + <Box sx={{ typography: 'caption', color: 'error.main' }}> + {t('validation.startDateMustBeBeforeEndDate')} + </Box> + )} <Controller name="end_date" control={control} + rules={{ + required: true, + validate: () => + Number(getValues('start_date')) <= + Number(getValues('end_date')), + }} defaultValue={today} render={({ field }) => ( <DatePicker @@ -232,8 +254,7 @@ function NewGuestRole({ guest, reloadGuestInfo }: NewGuestRoleProps) { label={t('input.roleEndDate')} disabled={!roleTypeChoice} value={field.value} - minDate={today} - maxDate={todayPlusMaxDays()} + maxDate={maxDate} inputFormat="yyyy-MM-dd" onChange={(value) => { field.onChange(value) @@ -242,10 +263,9 @@ function NewGuestRole({ guest, reloadGuestInfo }: NewGuestRoleProps) { /> )} /> - <TextField id="contact" - label={t('input.contact')} + label={t('input.contactPersonUnit')} multiline rows={5} {...register('contact_person_unit')} diff --git a/frontend/src/routes/sponsor/register/frontPage.tsx b/frontend/src/routes/sponsor/register/frontPage.tsx index 3797e4d08059e5d1a2d9c64edf29a895272903b0..5ad6ac4ffc4398a8bff0971ce52480c86347b282 100644 --- a/frontend/src/routes/sponsor/register/frontPage.tsx +++ b/frontend/src/routes/sponsor/register/frontPage.tsx @@ -6,6 +6,8 @@ import SearchIcon from '@mui/icons-material/Search' import { useTranslation } from 'react-i18next' import { debounce } from 'lodash' import React, { useState } from 'react' +import { Box, styled } from '@mui/system' +import { fetchJsonOpts } from 'utils' type Guest = { first: string @@ -14,21 +16,33 @@ type Guest = { value: string } +const StyledSpan = styled('span')({ + color: 'red', +}) + function FrontPage() { const [t] = useTranslation('common') const [guests, setGuests] = useState<Guest[]>([]) + const [searchHasInput, setSearchHasInput] = useState(false) - const getGuests = async (event: React.ChangeEvent<HTMLInputElement>) => { + const getGuests = (event: React.ChangeEvent<HTMLInputElement>) => { if (event.target.value) { - console.log('searching') - const response = await fetch( - `/api/ui/v1/person/search/${event.target.value}?format=json` - ) - const repjson = await response.json() - console.log(repjson) - if (response.ok) { - setGuests(repjson) - } + setSearchHasInput(true) + fetch(`/api/ui/v1/person/search/${event.target.value}`, fetchJsonOpts()) + .then((response) => { + if (response.ok) { + return response.json() + } + setSearchHasInput(false) + return [] + }) + .then((responseJson) => { + setGuests(responseJson) + }) + .catch(() => {}) + } else { + setSearchHasInput(false) + setGuests([]) } } return ( @@ -36,36 +50,53 @@ function FrontPage() { <SponsorGuestButtons registerNewGuestActive /> <h2>{t('register.registerHeading')}</h2> <p>{t('register.registerText')}</p> - <TextField - InputProps={{ - endAdornment: ( - <InputAdornment position="end"> - <SearchIcon /> - </InputAdornment> - ), + <Box + sx={{ + borderStyle: () => (guests.length > 0 ? 'solid' : 'none'), + borderRadius: '5px', + borderColor: 'secondary.main', + padding: '7px 12px', }} - fullWidth - placeholder="Mobile phone, e-mail" - onChange={debounce(getGuests, 600)} - /> - {guests ? ( - guests.map((guest) => { - const guestTo = `/sponsor/guest/${guest.pid}` - return ( - <MenuItem - key={`${guest.pid}-${guest.value}`} - component={Link} - to={guestTo} - > - {guest.first} {guest.last} - <br /> - {guest.value} - </MenuItem> - ) - }) - ) : ( - <></> - )} + > + <TextField + variant="standard" + error={searchHasInput && guests.length === 0} + InputProps={{ + endAdornment: ( + <InputAdornment position="end"> + <SearchIcon /> + </InputAdornment> + ), + }} + fullWidth + placeholder="Mobile phone, e-mail" + onChange={debounce(getGuests, 600)} + /> + {guests.length === 0 && searchHasInput ? ( + <StyledSpan>{t('register.noResults')}</StyledSpan> + ) : ( + guests.map((guest) => { + const guestTo = `/sponsor/guest/${guest.pid}` + return ( + <MenuItem + sx={{ + '&:hover': { + fontWeight: 'bold', + }, + }} + key={`${guest.pid}-${guest.value}`} + component={Link} + to={guestTo} + > + {guest.first} {guest.last} + <br /> + {guest.value} + </MenuItem> + ) + }) + )} + </Box> + <br /> <Button variant="contained" color="secondary" diff --git a/frontend/src/routes/sponsor/register/stepPersonForm.tsx b/frontend/src/routes/sponsor/register/stepPersonForm.tsx index e796fa3361a2fffa1e6843e4237c7e84ea017bf0..1bccad5a56e43c6847053c8e47c097bfc0a5787a 100644 --- a/frontend/src/routes/sponsor/register/stepPersonForm.tsx +++ b/frontend/src/routes/sponsor/register/stepPersonForm.tsx @@ -19,6 +19,7 @@ import React, { useImperativeHandle, useState, } from 'react' +import { addDays } from 'date-fns/fp' import { useTranslation } from 'react-i18next' import { RegisterFormData } from './formData' import { PersonFormMethods } from './personFormMethods' @@ -44,9 +45,10 @@ const StepPersonForm = forwardRef( const [selectedRoleType, setSelectedRoleType] = useState( formData.role_type ? formData.role_type : '' ) + const today = new Date() + const [todayPlusMaxDays, setTodayPlusMaxDays] = useState(today) const roleTypes = useRoleTypes() const { displayContactAtUnit, displayComment } = useContext(FeatureContext) - const today: Date = new Date() const roleTypeSort = () => (a: RoleTypeData, b: RoleTypeData) => { if (i18n.language === 'en') { @@ -90,6 +92,14 @@ const StepPersonForm = forwardRef( const handleRoleTypeChange = (event: any) => { setValue('role_type', event.target.value) setSelectedRoleType(event.target.value) + const selectedRoleTypeInfo = roleTypes.find( + (rt) => rt.id === event.target.value + ) + if (selectedRoleTypeInfo === undefined) { + setTodayPlusMaxDays(today) + } else { + setTodayPlusMaxDays(addDays(selectedRoleTypeInfo.max_days)(today)) + } } function doSubmit() { @@ -210,6 +220,7 @@ const StepPersonForm = forwardRef( <DatePicker mask="____-__-__" label={t('input.roleStartDate')} + maxDate={todayPlusMaxDays} value={field.value} inputFormat="yyyy-MM-dd" onChange={(value) => { @@ -232,6 +243,7 @@ const StepPersonForm = forwardRef( <DatePicker mask="____-__-__" label={t('input.roleEndDate')} + maxDate={todayPlusMaxDays} value={field.value} inputFormat="yyyy-MM-dd" onChange={(value) => { diff --git a/greg/admin.py b/greg/admin.py index c850afc5309afa8dbd95e8acedeafa01a265b6e4..2d9252c444828ffde1b216a6882189c88b3dd62a 100644 --- a/greg/admin.py +++ b/greg/admin.py @@ -135,12 +135,20 @@ class OrganizationalUnitInline(admin.TabularInline): class SponsorAdmin(VersionAdmin): - list_display = ("id", "feide_id") + list_display = ("id", "feide_id", "first_name", "last_name") inlines = (OrganizationalUnitInline,) readonly_fields = ("id", "created", "updated") class SponsorOrganizationalUnitAdmin(VersionAdmin): + list_display = ( + "id", + "sponsor", + "organizational_unit", + "hierarchical_access", + "source", + "automatic", + ) readonly_fields = ("id", "created", "updated") diff --git a/greg/api/serializers/consent.py b/greg/api/serializers/consent.py index 2d1eadf6dad2599d1e188199d956d73060bb753b..ef3dff43184eba72e70b78b56b7319407891528d 100644 --- a/greg/api/serializers/consent.py +++ b/greg/api/serializers/consent.py @@ -6,7 +6,9 @@ from greg.models import Consent class ConsentSerializerBrief(serializers.ModelSerializer): type = ConsentTypeSerializerBrief(read_only=True) + choice = serializers.CharField(read_only=True, source="choice.value") class Meta: model = Consent - fields = ["type", "consent_given_at"] + queryset = Consent.objects.all().select_related("choice") + fields = ["type", "consent_given_at", "choice"] diff --git a/greg/api/serializers/consent_type.py b/greg/api/serializers/consent_type.py index 407777f6026e7ae0602988889657b2e764d2b71c..fefe1f19caca36bb086422bd2c2cb77371d05781 100644 --- a/greg/api/serializers/consent_type.py +++ b/greg/api/serializers/consent_type.py @@ -33,8 +33,4 @@ class ConsentTypeSerializer(ModelSerializer): class ConsentTypeSerializerBrief(ModelSerializer): class Meta: model = ConsentType - fields = [ - "identifier", - "valid_from", - "user_allowed_to_change", - ] + fields = ["identifier", "mandatory"] diff --git a/greg/management/commands/populate_test_data.py b/greg/management/commands/populate_test_data.py index 72f6d1275f7e67c81b33f72f33afb4114371821f..f978003624786d70ed7c9daf69864f8d5965ef81 100644 --- a/greg/management/commands/populate_test_data.py +++ b/greg/management/commands/populate_test_data.py @@ -22,11 +22,10 @@ one of them has denied the other one. """ import datetime -from django.core.management.base import CommandError from django.db import connection from django.conf import settings -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from django.utils import timezone from greg.models import ( @@ -327,15 +326,27 @@ class DatabasePopulation: invitation=invitation, expire=timezone.now() - datetime.timedelta(days=32), ) + mandatory_consent_type = ConsentType.objects.get( + identifier=CONSENT_IDENT_MANDATORY + ) Consent.objects.create( person=adam, - type=ConsentType.objects.get(identifier=CONSENT_IDENT_MANDATORY), + type=mandatory_consent_type, consent_given_at=datetime.date.today() - datetime.timedelta(days=10), + choice=ConsentChoice.objects.get( + consent_type=mandatory_consent_type, value="yes" + ), + ) + optional_consent_type = ConsentType.objects.get( + identifier=CONSENT_IDENT_OPTIONAL ) Consent.objects.create( person=adam, - type=ConsentType.objects.get(identifier=CONSENT_IDENT_OPTIONAL), + type=optional_consent_type, consent_given_at=datetime.date.today() - datetime.timedelta(days=10), + choice=ConsentChoice.objects.get( + consent_type=optional_consent_type, value="no" + ), ) def _add_expired_person(self): @@ -383,10 +394,16 @@ class DatabasePopulation: invitation=invitation, expire=timezone.now() - datetime.timedelta(days=204), ) + mandatory_consent_type = ConsentType.objects.get( + identifier=CONSENT_IDENT_MANDATORY + ) Consent.objects.create( person=esther, - type=ConsentType.objects.get(identifier=CONSENT_IDENT_MANDATORY), - consent_given_at=datetime.date.today() - datetime.timedelta(days=206), + type=mandatory_consent_type, + consent_given_at=datetime.date.today() - datetime.timedelta(days=10), + choice=ConsentChoice.objects.get( + consent_type=mandatory_consent_type, value="yes" + ), ) def populate_database(self): diff --git a/greg/migrations/0019_add_ou_parent_relatedname.py b/greg/migrations/0019_add_ou_parent_relatedname.py new file mode 100644 index 0000000000000000000000000000000000000000..87eae55db0dd2b6ccdb98a68ca2b3a164b296072 --- /dev/null +++ b/greg/migrations/0019_add_ou_parent_relatedname.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.9 on 2021-12-03 08:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("greg", "0018_alter_identity_type"), + ] + + operations = [ + migrations.AlterField( + model_name="organizationalunit", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="children", + to="greg.organizationalunit", + ), + ), + ] diff --git a/greg/models.py b/greg/models.py index 6d04f570ad5bdef058f1373b291b67491a4ccf3b..a3eb03a1a66ff1fb8e9b6bb9e3f7ff4fcbb9ceab 100644 --- a/greg/models.py +++ b/greg/models.py @@ -54,7 +54,7 @@ class Person(BaseModel): objects = PersonManager() def __str__(self): - return "{} {} ({})".format(self.first_name, self.last_name, self.pk) + return f"{self.first_name} {self.last_name} ({self.pk})" def __repr__(self): return "{}(id={!r}, first_name={!r}, last_name={!r})".format( @@ -197,7 +197,7 @@ class RoleType(BaseModel): max_days = models.IntegerField(default=365) def __str__(self): - return "{} ({})".format(str(self.name_en or self.name_nb), self.identifier) + return f"{str(self.name_en or self.name_nb)} ({self.identifier})" def __repr__(self): return "{}(pk={!r}, identifier={!r}, name_nb={!r}, name_en={!r})".format( @@ -243,6 +243,12 @@ class Role(BaseModel): self.__class__.__name__, self.pk, self.person, self.type ) + def __str__(self) -> str: + return ( + f"{self.__class__.__name__}(id={self.pk}, " + f"role={self.person}/{self.type}@{self.orgunit.name_nb})" + ) + class Notification(BaseModel): """A change notification that should be delivered to a message queue.""" @@ -253,7 +259,7 @@ class Notification(BaseModel): issued_at = models.IntegerField() meta = models.JSONField(null=True, blank=True) - def __repr__(self): + def __repr__(self) -> str: return "{}(id={!r}, identifier={!r}, object_type={!r}, operation={!r}, issued_at={!r}, meta={!r})".format( self.__class__.__name__, self.pk, @@ -295,7 +301,7 @@ class Identity(BaseModel): ) verified_at = models.DateTimeField(null=True, blank=True) - def __str__(self): + def __str__(self) -> str: return "{}(id={!r}, type={!r}, value={!r})".format( self.__class__.__name__, self.pk, @@ -303,7 +309,7 @@ class Identity(BaseModel): self.value, ) - def __repr__(self): + def __repr__(self) -> str: return "{}(id={!r}, person_id={!r}, type={!r}, source={!r}, value={!r}, verified_by={!r}, verified_at={!r})".format( self.__class__.__name__, self.pk, @@ -335,10 +341,10 @@ class ConsentType(BaseModel): user_allowed_to_change = models.BooleanField() mandatory = models.BooleanField(default=False) - def __str__(self): - return "{} ({})".format(str(self.name_en or self.name_nb), self.identifier) + def __str__(self) -> str: + return f"{str(self.name_en or self.name_nb)} ({self.identifier})" - def __repr__(self): + def __repr__(self) -> str: return "{}(id={!r}, identifier={!r}, name_en={!r}, valid_from={!r}, user_allowed_to_change={!r})".format( self.__class__.__name__, self.pk, @@ -370,10 +376,13 @@ class ConsentChoice(BaseModel): ), ) - def __str__(self): - return "{} ({})".format(str(self.text_en or self.text_nb), self.value) + def __str__(self) -> str: + return ( + f"{self.__class__.__name__}(id={self.pk}, value={self.value}, " + f"type={self.consent_type}))" + ) - def __repr__(self): + def __repr__(self) -> str: return "{}(id={!r}, consent_type={!r} value={!r}, text_en={!r}, text_nb={!r}, text_nn={!r})".format( self.__class__.__name__, self.pk, @@ -411,7 +420,14 @@ class Consent(BaseModel): ) ] - def __repr__(self): + def __str__(self) -> str: + return ( + f"{self.__class__.__name__}(id={self.pk}, " + f"person={self.person}, type={self.type}, " + f"consent_given_at={self.consent_given_at})" + ) + + def __repr__(self) -> str: return "{}(id={!r}, person={!r}, type={!r}, consent_given_at={!r})".format( self.__class__.__name__, self.pk, @@ -436,7 +452,13 @@ class OuIdentifier(BaseModel): models.UniqueConstraint(name="unique_identifier", fields=["name", "value"]) ] - def __repr__(self): + def __str__(self) -> str: + return ( + f"{self.__class__.__name__}(id={self.pk}, " + f"name={self.name}, value={self.value})" + ) + + def __repr__(self) -> str: return "{}(id={!r}, name={!r}, value={!r})".format( self.__class__.__name__, self.pk, self.name, self.value ) @@ -449,17 +471,46 @@ class OrganizationalUnit(BaseModel): name_nb = models.CharField(max_length=256) name_en = models.CharField(max_length=256) - parent = models.ForeignKey("self", on_delete=models.PROTECT, null=True, blank=True) + parent = models.ForeignKey( + "self", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="children", + ) active = models.BooleanField(default=True) deleted = models.BooleanField(default=False) - def __repr__(self): + def __repr__(self) -> str: return "{}(id={!r}, name_en={!r}, parent={!r})".format( self.__class__.__name__, self.pk, self.name_en, self.parent ) - def __str__(self): - return "{}".format(str(self.name_en or self.name_nb)) + def __str__(self) -> str: + return f"{self.name_en or self.name_nb}" + + def fetch_tree(self): + """ + Convenience method for fetching a set of the entire tree below + this unit including itself. + """ + units = set() + self.get_children(units) + return units + + def get_children(self, units: set["OrganizationalUnit"]): + """ + Fetch the entire tree of units with this unit at the top. + + Expects an empty set for bookkeeping to prevent an infinite loop. + """ + # shortcut to prevent infinite loop in case of a loop in the tree + if self.id in [i.id for i in units]: + return + # Recursion loop for fetching children of children of ... + units.add(self) + for child in self.children.all(): + child.get_children(units) class Sponsor(BaseModel): @@ -478,10 +529,10 @@ class Sponsor(BaseModel): related_name="sponsor_unit", ) - def __str__(self): - return "{} ({} {})".format(self.feide_id, self.first_name, self.last_name) + def __str__(self) -> str: + return f"{self.feide_id} ({self.first_name} {self.last_name})" - def __repr__(self): + def __repr__(self) -> str: return "{}(id={!r}, feide_id={!r}, first_name={!r}, last_name={!r})".format( self.__class__.__name__, self.pk, @@ -495,6 +546,24 @@ class Sponsor(BaseModel): models.UniqueConstraint(name="unique_feide_id", fields=["feide_id"]) ] + def get_allowed_units(self) -> set[OrganizationalUnit]: + """ + Fetch every unit the sponsor has access to. + + This includes both through direct access and hierarchical. + """ + connections = self.link_sponsor.all() + ha_units = [i.organizational_unit for i in connections if i.hierarchical_access] + + units: set[OrganizationalUnit] = set() # Collector set + # Add units accessible through hierarchical access + for unit in ha_units: + units.update(unit.fetch_tree()) + + # Add units accessible through direct access + units = units.union({i.organizational_unit for i in connections}) + return units + class SponsorOrganizationalUnit(BaseModel): """ @@ -519,7 +588,13 @@ class SponsorOrganizationalUnit(BaseModel): ) ] - def __repr__(self): + def __str__(self) -> str: + return ( + f"{self.__class__.__name__}(id={self.pk}, sponsor={self.sponsor}, " + f"org_unit={self.organizational_unit})" + ) + + def __repr__(self) -> str: return "{}(id={!r}, sponsor={!r}, organizational_unit={!r}, hierarchical_access={!r})".format( self.__class__.__name__, self.pk, @@ -543,6 +618,12 @@ class InvitationLink(BaseModel): ) expire = models.DateTimeField(blank=False, null=False) + def __str__(self) -> str: + return ( + f"{self.__class__.__name__}(id={self.pk}, invitation={self.invitation}, " + f"uuid={self.uuid}, expire={self.expire})" + ) + class Invitation(BaseModel): """ @@ -552,3 +633,6 @@ class Invitation(BaseModel): """ role = models.ForeignKey("Role", null=False, blank=False, on_delete=models.CASCADE) + + def __str__(self) -> str: + return f"{self.__class__.__name__}(id={self.pk}, role={self.role})" diff --git a/greg/signals.py b/greg/signals.py index 5d427897b25e54fcd26edf53092444796c3c1331..de65ab3e8c62b15e960b358201c094eacdaccd04 100644 --- a/greg/signals.py +++ b/greg/signals.py @@ -134,9 +134,11 @@ def save_notification_callback(sender, instance, created, *args, **kwargs): return # Queue future notifications on start and end date for roles if isinstance(instance, Role) and hasattr(instance, "_changed_fields"): + today = datetime.date.today() if ( "start_date" in instance._changed_fields # pylint: disable=protected-access and instance.start_date + and instance.start_date != today ): Schedule.objects.create( func="greg.signals._queue_role_start_notification", @@ -144,7 +146,10 @@ def save_notification_callback(sender, instance, created, *args, **kwargs): next_run=date_to_datetime_midnight(instance.start_date), schedule_type=Schedule.ONCE, ) - if "end_date" in instance._changed_fields: # pylint: disable=protected-access + if ( + "end_date" in instance._changed_fields # pylint: disable=protected-access + and instance.end_date != today + ): Schedule.objects.create( func="greg.signals._queue_role_end_notification", args=f"{instance.id},True", diff --git a/greg/tests/api/test_person.py b/greg/tests/api/test_person.py index 541c916a67bd10da248e3f2625724baa0205cec0..9be933358048289b9f7ee98d3856c417be9ec669 100644 --- a/greg/tests/api/test_person.py +++ b/greg/tests/api/test_person.py @@ -8,6 +8,7 @@ from rest_framework.status import HTTP_200_OK from rest_framework.test import APIClient from greg.models import ( + Consent, Identity, Sponsor, RoleType, @@ -60,13 +61,25 @@ def role_data_guest( @pytest.mark.django_db -def test_get_person(client, person_foo): +def test_get_person(client, person_foo, consent_type_foo): + Consent.objects.create( + person=person_foo, + type=consent_type_foo, + choice=consent_type_foo.choices.get(value="yes"), + ) resp = client.get(reverse("v1:person-detail", kwargs={"id": person_foo.id})) assert resp.status_code == HTTP_200_OK data = resp.json() assert data.get("id") == person_foo.id assert data.get("first_name") == person_foo.first_name assert data.get("last_name") == person_foo.last_name + assert data.get("consents") == [ + { + "consent_given_at": None, + "type": {"identifier": "foo", "mandatory": False}, + "choice": "yes", + } + ] @pytest.mark.django_db diff --git a/greg/tests/conftest.py b/greg/tests/conftest.py index 8315054552d2a98a70dd77e34af6991e5b80d5e8..7b6acedc3680b78217467923cae30bed61fa769a 100644 --- a/greg/tests/conftest.py +++ b/greg/tests/conftest.py @@ -238,3 +238,32 @@ def notification(): meta="foometa", ) return Notification.objects.get(id=1) + + +@pytest.fixture +def looped_units(): + # the loop + a = OrganizationalUnit.objects.create() + b = OrganizationalUnit.objects.create(parent=a) + c = OrganizationalUnit.objects.create(parent=b) + d = OrganizationalUnit.objects.create(parent=c) + e = OrganizationalUnit.objects.create(parent=d) + a.parent = e + a.save() + return a, b, c, d, e + + +@pytest.fixture +def loop_sponsor(looped_units, unit_foo) -> Sponsor: + """A sponsor that is connected to a loop, and a single unit.""" + loop_unit = looped_units[2] + sp = Sponsor.objects.create() + # Connection to the loop + SponsorOrganizationalUnit.objects.create( + sponsor=sp, organizational_unit=loop_unit, hierarchical_access=True + ) + # Stand alone connection + SponsorOrganizationalUnit.objects.create( + sponsor=sp, organizational_unit=unit_foo, hierarchical_access=False + ) + return sp diff --git a/greg/tests/models/test_organizational_unit.py b/greg/tests/models/test_organizational_unit.py index ce2043065ff5b367bbb1975976d7173130b55385..dfbcfa3ebe9621ca45113bd1c6eec4e41a486e55 100644 --- a/greg/tests/models/test_organizational_unit.py +++ b/greg/tests/models/test_organizational_unit.py @@ -18,3 +18,10 @@ def test_org_repr(unit_foo): @pytest.mark.django_db def test_org_str(unit_foo): assert str(unit_foo) == "foo_unit" + + +@pytest.mark.django_db +def test_fetch_tree_loop(looped_units): + a = looped_units[0] + units = a.fetch_tree() + assert [x.id for x in units] == [i.id for i in looped_units] diff --git a/greg/tests/models/test_sponsor.py b/greg/tests/models/test_sponsor.py index 581111194aca42b87195e0cab985ff17aec618ef..322ada7d7f559a7a319d53de5194317989360980 100644 --- a/greg/tests/models/test_sponsor.py +++ b/greg/tests/models/test_sponsor.py @@ -63,3 +63,10 @@ def test_sponsor_repr(sponsor_guy): @pytest.mark.django_db def test_sponsor_str(sponsor_guy): assert str(sponsor_guy) == "guy@example.org (Sponsor Guy)" + + +@pytest.mark.django_db +def test_get_allowed_loop(loop_sponsor, looped_units, unit_foo): + units = loop_sponsor.get_allowed_units() + expected = [i.id for i in looped_units] + [unit_foo.id] + assert [x.id for x in units] == expected diff --git a/gregui/admin.py b/gregui/admin.py index 7c3f771755952b78e409029ce4c17be9bd6412eb..7b80a29b329de04566c2edcd312c97918686c126 100644 --- a/gregui/admin.py +++ b/gregui/admin.py @@ -5,7 +5,7 @@ from gregui.models import EmailTemplate, GregUserProfile class GregUserProfileAdmin(VersionAdmin): - pass + list_display = ["id", "userid_feide", "person", "sponsor"] class EmailTemplateAdmin(VersionAdmin): diff --git a/gregui/api/serializers/role.py b/gregui/api/serializers/role.py index 0b867f092a1ca0f6eea1d9a1f71cbd9aa703a175..b092f8b8102a9079fcb9cdd8cc494823f0cc03b1 100644 --- a/gregui/api/serializers/role.py +++ b/gregui/api/serializers/role.py @@ -28,38 +28,13 @@ class RoleSerializerUi(serializers.ModelSerializer): ) ] - def validate_start_date(self, start_date): - """Enfore rules for start_date. - - Must be present, can be blank, before today not allowed. - """ - if not start_date: - return start_date - today = datetime.date.today() - # New start dates cannot be in the past - if start_date < today: - raise serializers.ValidationError("Start date cannot be in the past") - - return start_date - - def validate_end_date(self, end_date): - """Ensure rules for end_date are followed""" - today = datetime.date.today() - if end_date < today: - raise serializers.ValidationError("End date cannot be in the past") - if self.instance and self.instance.end_date < today: - raise serializers.ValidationError("Role has ended, cannot change end date") - return end_date - def validate_orgunit(self, unit): """Enforce rules related to orgunit""" - sponsor = self.context["sponsor"] - units = sponsor.units.all() - # Restrict to a sponsor's own units - if not units or unit not in units: - raise ValidationError( - "A sponsor can only make changes to roles at units they are sponsors for." - ) + # A sponsor can only add roles to units they are sponsors at, or children of + # that unit if they have hierarchical access. + allowed_units = self.context["sponsor"].get_allowed_units() + if unit not in allowed_units: + raise ValidationError("You can only edit roles connected to your units.") return unit def validate(self, attrs): @@ -87,10 +62,10 @@ class RoleSerializerUi(serializers.ModelSerializer): raise serializers.ValidationError( "End date cannot be before start date." ) - # If we are updating an existing roles, we must be the sponsor of the role - sponsor = self.context["sponsor"] - if self.instance and self.instance.sponsor != sponsor: - raise ValidationError("You can only edit your own roles.") + # A sponsor can not modify roles connected to units they do not have access to + allowed_units = self.context["sponsor"].get_allowed_units() + if self.instance and self.instance.orgunit not in allowed_units: + raise ValidationError("You can only edit roles connected to your units.") return attrs diff --git a/gregui/api/urls.py b/gregui/api/urls.py index 3b991355256b6eb6fb093284013e50408fa90aca..c2fb05dab808f6cd78d1c03c15685a977084efed 100644 --- a/gregui/api/urls.py +++ b/gregui/api/urls.py @@ -14,6 +14,7 @@ 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 +from gregui.api.views.userinfo import UserInfoView router = DefaultRouter(trailing_slash=False) router.register(r"role", RoleInfoViewSet, basename="role") @@ -40,4 +41,5 @@ urlpatterns += [ PersonSearchViewSet.as_view({"get": "list"}), name="person-search", ), + path("userinfo/", UserInfoView.as_view(), name="userinfo"), ] diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py index a979d534b95357fe0a02a5e5ee7efb2275dda1a7..54cc778e9dad6c7b00f396dec78eb16f7d0403bd 100644 --- a/gregui/api/views/invitation.py +++ b/gregui/api/views/invitation.py @@ -118,15 +118,33 @@ class CheckInvitationView(APIView): Uses post to prevent invite id from showing up in various logs. """ - invite_id = request.data.get("uuid") + invite_id = request.data.get("invite_token") if not invite_id: - return Response(status=status.HTTP_403_FORBIDDEN) + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + "code": "missing_invite_token", + "message": "An invite token is required", + }, + ) try: invite_link = InvitationLink.objects.get(uuid=invite_id) except (InvitationLink.DoesNotExist, exceptions.ValidationError): - return Response(status=status.HTTP_403_FORBIDDEN) + return Response( + status=status.HTTP_403_FORBIDDEN, + data={ + "code": "invalid_invite_token", + "message": "Invite token is invalid", + }, + ) if invite_link.expire <= timezone.now(): - return Response(status=status.HTTP_403_FORBIDDEN) + return Response( + status=status.HTTP_403_FORBIDDEN, + data={ + "code": "expired_invite_token", + "message": "Invite token has expired", + }, + ) request.session["invite_id"] = invite_id return Response(status=status.HTTP_200_OK) @@ -178,9 +196,15 @@ class InvitedGuestView(GenericAPIView): try: invite_link = InvitationLink.objects.get(uuid=invite_id) except (InvitationLink.DoesNotExist, exceptions.ValidationError): - return Response(status=status.HTTP_403_FORBIDDEN) + return Response( + status=status.HTTP_403_FORBIDDEN, + data={"code": "invalid_invite", "message": "Invalid invite"}, + ) if invite_link.expire <= timezone.now(): - return Response(status=status.HTTP_403_FORBIDDEN) + return Response( + status=status.HTTP_403_FORBIDDEN, + data={"code": "invite_expired", "message": "Invite expired"}, + ) # if invite_id: invite_link = InvitationLink.objects.get(uuid=invite_id) @@ -217,7 +241,7 @@ class InvitedGuestView(GenericAPIView): "role_name_en": role.type.name_en, "start": role.start_date, "end": role.end_date, - "comments": role.comments, + "contact_person_unit": role.contact_person_unit, }, "meta": {"session_type": session_type}, } @@ -257,12 +281,21 @@ class InvitedGuestView(GenericAPIView): if illegal_fields: return Response( status=status.HTTP_400_BAD_REQUEST, - data={"error": {"cannot_update_fields": illegal_fields}}, + data={ + "code": "cannot_update_fields", + "message": f"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 - return Response(status=status.HTTP_400_BAD_REQUEST) + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + "code": "update_national_id_not_allowed", + "message": "Not allowed to update verified national ID", + }, + ) with transaction.atomic(): # Note this only serializes data for the person, it does not look at other sections diff --git a/gregui/api/views/person.py b/gregui/api/views/person.py index fec45bb3b1467dccb7dd6dcae4b5e01705a9c2da..b7173c76af9e087d5b31047b6f7df285148dfedc 100644 --- a/gregui/api/views/person.py +++ b/gregui/api/views/person.py @@ -57,7 +57,10 @@ class PersonSearchViewSet(mixins.ListModelMixin, GenericViewSet): class GuestInfoViewSet(mixins.ListModelMixin, GenericViewSet): """ - Fetch all the sponsor's guests. + Fetch all guests at the sponsor's units. + + If the Sponsor's connection to the Unit is marked with hierarchical access, guests + at child units of the Unit are included. Lists all persons connected to the roles the logged in sponsor is connected to. """ @@ -67,5 +70,17 @@ class GuestInfoViewSet(mixins.ListModelMixin, GenericViewSet): serializer_class = SpecialPersonSerializer def get_queryset(self): + """ + Fetch Persons connected to the sponsor some way. + + Any person with a role connected to the same unit as the sponsor, or a unit + that the sponsor is connected to through hierarchical access. + """ + user = GregUserProfile.objects.get(user=self.request.user) - return Person.objects.filter(roles__sponsor=user.sponsor).distinct() + units = user.sponsor.get_allowed_units() + return ( + Person.objects.filter(roles__orgunit__in=list(units)) + .distinct() + .order_by("id") + ) diff --git a/gregui/api/views/userinfo.py b/gregui/api/views/userinfo.py index bba967c519e0c611d7018bd651a47c35bd984d96..f376fb0ad360d08257e4c52472ce80f06b19bd2d 100644 --- a/gregui/api/views/userinfo.py +++ b/gregui/api/views/userinfo.py @@ -21,8 +21,8 @@ class UserInfoView(APIView): Quick draft, we might want to expand this later. """ - authentication_classes: Sequence[Type[BaseAuthentication]] = [SessionAuthentication] - permission_classes: Sequence[Type[BasePermission]] = [AllowAny] + authentication_classes = [SessionAuthentication] + permission_classes = [AllowAny] def get(self, request, format=None): """ @@ -32,6 +32,7 @@ class UserInfoView(APIView): invitation id. Pure django users, and anonymous users are denied access. """ user = request.user + invite_id = request.session.get("invite_id") auth_type = "invite" @@ -40,6 +41,8 @@ class UserInfoView(APIView): person = None sponsor = None + user_profile = None + content = { "feide_id": None, "sponsor_id": None, @@ -48,34 +51,57 @@ class UserInfoView(APIView): "auth_type": auth_type, } - # Fetch sponsor and/or person object from profile of authenticated user if user.is_authenticated: try: - user_profile = GregUserProfile.objects.get(user=user) - sponsor = user_profile.sponsor - person = user_profile.person - content.update( - { - "feide_id": user_profile.userid_feide, - } - ) + user_profile = GregUserProfile.objects.select_related( + "person", "sponsor" + ).get(user=user) except GregUserProfile.DoesNotExist: - return Response(status=HTTP_403_FORBIDDEN) + return Response( + status=HTTP_403_FORBIDDEN, + data={ + "code": "no_user_profile", + "message": "Authenticated, but missing user profile", + }, + ) + # Fetch sponsor and/or person object from profile of authenticated user + if user_profile: + sponsor = user_profile.sponsor + person = user_profile.person + content["feide_id"] = user_profile.userid_feide # Or fetch person info for invited guest elif invite_id: - link = InvitationLink.objects.get(uuid=invite_id) + link = InvitationLink.objects.select_related( + "invitation__role__person", + ).get(uuid=invite_id) person = link.invitation.role.person - # Otherwise, deny access else: return Response(status=HTTP_403_FORBIDDEN) - # Add sponsor fields if sponsor object present if sponsor: - content.update({"sponsor_id": user_profile.sponsor.id}) - # Add person fields if person object present + content["sponsor_id"] = sponsor.id + if person: + person_roles = [ + { + "id": role.id, + "ou_nb": role.orgunit.name_nb, + "ou_en": role.orgunit.name_en, + "name_nb": role.type.name_nb, + "name_en": role.type.name_en, + "start_date": role.start_date, + "end_date": role.end_date, + "sponsor": { + "first_name": role.sponsor.first_name, + "last_name": role.sponsor.last_name, + }, + } + for role in person.roles.all().select_related( + "orgunit", "type", "sponsor" + ) + ] content.update( { "person_id": person.id, @@ -86,22 +112,7 @@ class UserInfoView(APIView): and person.private_mobile.value, "fnr": person.fnr and "".join((person.fnr.value[:-5], "*****")), "passport": person.passport and person.passport.value, - "roles": [ - { - "id": role.id, - "ou_nb": role.orgunit.name_nb, - "ou_en": role.orgunit.name_en, - "name_nb": role.type.name_nb, - "name_en": role.type.name_en, - "start_date": role.start_date, - "end_date": role.end_date, - "sponsor": { - "first_name": role.sponsor.first_name, - "last_name": role.sponsor.last_name, - }, - } - for role in person.roles.all() - ], + "roles": person_roles, } ) return Response(content) diff --git a/gregui/tests/api/serializers/test_role.py b/gregui/tests/api/serializers/test_role.py index b36c2b7c4e152b7a82851a2612cd2806f0e276ba..1a43a85692d0fe0ff0db8afad96625c004dc6173 100644 --- a/gregui/tests/api/serializers/test_role.py +++ b/gregui/tests/api/serializers/test_role.py @@ -25,8 +25,8 @@ def test_minimum_ok(role, sponsor_foo): @pytest.mark.django_db -def test_start_date_past_fail(role, sponsor_foo): - """Should fail because of start_date in the past""" +def test_start_date_past_ok(role, sponsor_foo): + """Should work even though start_date in the past""" ser = RoleSerializerUi( data={ "person": role.person.id, @@ -37,41 +37,29 @@ def test_start_date_past_fail(role, sponsor_foo): }, context={"sponsor": sponsor_foo}, ) - with pytest.raises( - ValidationError, - match=re.escape( - "{'start_date': [ErrorDetail(string='Start date cannot be in the past', code='invalid')]}" - ), - ): - ser.is_valid(raise_exception=True) + assert ser.is_valid(raise_exception=True) @pytest.mark.django_db -def test_end_date_past_fail(role, sponsor_foo): - """Should fail because of end_date in the past""" +def test_end_date_past_ok(role, sponsor_foo): + """Should work even though end_date in the past""" ser = RoleSerializerUi( data={ "person": role.person.id, "orgunit": role.orgunit.id, "type": role.type.id, - "start_date": datetime.date.today(), + "start_date": (timezone.now() - datetime.timedelta(days=12)).date(), "end_date": (timezone.now() - datetime.timedelta(days=10)).date(), }, context={"sponsor": sponsor_foo}, ) - with pytest.raises( - ValidationError, - match=re.escape( - "{'end_date': [ErrorDetail(string='End date cannot be in the past', code='invalid')]}" - ), - ): - ser.is_valid(raise_exception=True) + assert ser.is_valid(raise_exception=True) @pytest.mark.django_db -def test_end_date_expired_role_fail(role, sponsor_foo): - """New end date fail because role has ended""" - # Expire the role to ensure failure +def test_end_date_expired_role_ok(role, sponsor_foo): + """Editing an expired role is allowed""" + # Expire the role role.end_date = datetime.date.today() - datetime.timedelta(days=10) role.save() # Try to change it @@ -86,20 +74,13 @@ def test_end_date_expired_role_fail(role, sponsor_foo): }, context={"sponsor": sponsor_foo}, ) - # Verify that a validation error is raised - with pytest.raises( - ValidationError, - match=re.escape( - "{'end_date': [ErrorDetail(string='Role has ended, cannot change end date', code='invalid')]}" - ), - ): - ser.is_valid(raise_exception=True) + assert ser.is_valid(raise_exception=True) @pytest.mark.django_db def test_wrong_sponsor(role, sponsor_foo, sponsor_bar): - """Touching another sponsor's roles does not work""" - # Try to touch sponsor_foo's guest role as sponsor_bar + """Touching roles connected to another unit does not work""" + # Try to touch a unit at a unit we are not sponsor for ser = RoleSerializerUi( instance=role, data={ @@ -115,7 +96,7 @@ def test_wrong_sponsor(role, sponsor_foo, sponsor_bar): with pytest.raises( ValidationError, match=re.escape( - "{'non_field_errors': [ErrorDetail(string='You can only edit your own roles.', code='invalid')]}" + "{'orgunit': [ErrorDetail(string='You can only edit roles connected to your units.', code='invalid')]}" ), ): ser.is_valid(raise_exception=True) diff --git a/gregui/tests/api/views/test_invitation.py b/gregui/tests/api/views/test_invitation.py index 8a10b3727b196398e143cf0f25ad6e4165afe818..3fe44484be40671ca3ea00f56dcc4b65eff84c37 100644 --- a/gregui/tests/api/views/test_invitation.py +++ b/gregui/tests/api/views/test_invitation.py @@ -12,15 +12,21 @@ from greg.models import Consent, InvitationLink, Person, Identity @pytest.mark.django_db def test_post_invite(client): """Forbid access with bad invitation link uuid""" - response = client.post(reverse("gregui-v1:invite-verify"), data={"uuid": "baduuid"}) + response = client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": "baduuid"} + ) assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json() == { + "code": "invalid_invite_token", + "message": "Invite token is invalid", + } @pytest.mark.django_db def test_post_invite_ok(client, invitation_link): """Access okay with valid invitation link""" response = client.post( - reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid} + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} ) assert response.status_code == status.HTTP_200_OK @@ -29,23 +35,34 @@ def test_post_invite_ok(client, invitation_link): def test_post_invite_expired(client, invitation_link_expired): """Forbid access with expired invite link""" response = client.post( - reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link_expired.uuid} + reverse("gregui-v1:invite-verify"), + data={"invite_token": invitation_link_expired.uuid}, ) assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json() == { + "code": "expired_invite_token", + "message": "Invite token has expired", + } @pytest.mark.django_db def test_post_missing_invite_id(client): """Forbid access if no id provided.""" response = client.post(reverse("gregui-v1:invite-verify")) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == { + "code": "missing_invite_token", + "message": "An invite token is required", + } @pytest.mark.django_db def test_get_invited_info_no_session(client, invitation_link): """Forbid access if invite expired after session was created.""" # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # expire the invitation link invitation_link.expire = timezone.now() - datetime.timedelta(days=1) invitation_link.save() @@ -60,7 +77,9 @@ def test_get_invited_info_session_okay( ): person, invitation_link = invited_person # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # Get info response = client.get(reverse("gregui-v1:invited-info")) assert response.status_code == status.HTTP_200_OK @@ -82,7 +101,7 @@ def test_get_invited_info_session_okay( assert data.get("role") == dict( start=None, end="2050-10-15", - comments="", + contact_person_unit="", ou_name_en=unit_foo.name_en, ou_name_nb=unit_foo.name_nb, role_name_en=role_type_foo.name_en, @@ -95,7 +114,9 @@ def test_get_invited_info_expired_link( client, invitation_link, invitation_expired_date ): # Get a session while link is valid - client.get(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.get( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # Set expire link to expire long ago invlink = InvitationLink.objects.get(uuid=invitation_link.uuid) invlink.expire = invitation_expired_date @@ -110,7 +131,9 @@ def test_get_invited_info_expired_link( def test_invited_guest_can_post_information(client: APIClient, invited_person): person, invitation_link = invited_person # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) person = invitation_link.invitation.role.person assert person.private_mobile is None @@ -138,7 +161,9 @@ def test_post_invited_info_expired_session( client, invitation_link, invitation_expired_date ): # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # Set expire link to expire long ago invlink = InvitationLink.objects.get(uuid=invitation_link.uuid) invlink.expire = invitation_expired_date @@ -152,7 +177,9 @@ def test_post_invited_info_expired_session( @pytest.mark.django_db def test_post_invited_info_deleted_inv_link(client, invitation_link): # get a session - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) # Delete link invlink = InvitationLink.objects.get(uuid=invitation_link.uuid) invlink.delete() @@ -284,7 +311,9 @@ def test_name_update_not_allowed_if_feide_identity_is_present( ) invitation = create_invitation(role) invitation_link = create_invitation_link(invitation, invitation_valid_date) - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) person = invitation_link.invitation.role.person data = { @@ -330,7 +359,9 @@ def test_name_update_allowed_if_feide_identity_is_not_present( invitation_link = create_invitation_link( create_invitation(role), invitation_valid_date ) - client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}) + client.post( + reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid} + ) person = invitation_link.invitation.role.person data = { diff --git a/gregui/tests/api/views/test_role.py b/gregui/tests/api/views/test_role.py index 3b740f9ec366e315b1dbc661a0d73e80a3119758..26916673c704f1bf259848428f84954c48241d01 100644 --- a/gregui/tests/api/views/test_role.py +++ b/gregui/tests/api/views/test_role.py @@ -70,7 +70,7 @@ def test_role_sponsor_post_fail(client, log_in, user_sponsor, role): ) assert ( resp.content - == b'{"orgunit":["A sponsor can only make changes to roles at units they are sponsors for."]}' + == b'{"orgunit":["You can only edit roles connected to your units."]}' ) assert resp.status_code == status.HTTP_400_BAD_REQUEST @@ -99,24 +99,25 @@ def test_role_sponsor_patch_ok( @pytest.mark.django_db -def test_role_sponsor_patch_fail(client, log_in, user_sponsor, role): +def test_role_sponsor_patch_fail(client, log_in, user_sponsor_bar, role): """Should fail since we are not a sponsor at this unit.""" # Unit the sponsor is not connected to so that we fail - ou = OrganizationalUnit.objects.create(name_nb="foo", name_en="foo_en") + OrganizationalUnit.objects.create(name_nb="foo", name_en="foo_en") - log_in(user_sponsor) + log_in(user_sponsor_bar) + new_date = (timezone.now() + datetime.timedelta(days=10)).date() resp = client.patch( reverse("gregui-v1:role-detail", kwargs={"pk": role.id}), data={ - "orgunit": ou.id, + "end_date": new_date.strftime("%Y-%m-%d"), }, ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST assert ( resp.content - == b'{"orgunit":["A sponsor can only make changes to roles at units they are sponsors for."]}' + == b'{"non_field_errors":["You can only edit roles connected to your units."]}' ) - assert resp.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.django_db diff --git a/gregui/tests/api/views/test_userinfo.py b/gregui/tests/api/views/test_userinfo.py index cf1f331d48c6ff3ca1fe2d2ec47f4db756ac7c68..df7d3cb44888f333ac0939a4509c0ec88583fb0d 100644 --- a/gregui/tests/api/views/test_userinfo.py +++ b/gregui/tests/api/views/test_userinfo.py @@ -6,7 +6,7 @@ from rest_framework import status @pytest.mark.django_db def test_userinfo_anon_get(client): """Anonymous people should be forbidden.""" - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -16,7 +16,7 @@ def test_userinfo_invited_get(client, invitation_link): session = client.session session["invite_id"] = str(invitation_link.uuid) session.save() - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_200_OK assert response.json() == { "auth_type": "invite", @@ -48,7 +48,7 @@ def test_userinfo_invited_get(client, invitation_link): def test_userinfo_user_no_profile(client, log_in, user_no_profile): """Pure django users should be forbidden""" log_in(user_no_profile) - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -57,7 +57,7 @@ def test_userinfo_sponsor_get(client, log_in, user_sponsor): """Sponsors should get info about themselves""" log_in(user_sponsor) - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_200_OK assert response.json() == { "auth_type": "oidc", @@ -72,7 +72,7 @@ def test_userinfo_sponsor_get(client, log_in, user_sponsor): def test_userinfo_guest_get(client, log_in, user_person): """Logged in guests should get info about themself""" log_in(user_person) - response = client.get(reverse("api-userinfo")) + response = client.get(reverse("gregui-v1:userinfo")) assert response.status_code == status.HTTP_200_OK assert response.json() == { "auth_type": "oidc", diff --git a/gregui/tests/conftest.py b/gregui/tests/conftest.py index 8386752d53ad86292066851b8324c80d8b2e4a30..5fd153b0242d8c213d06b5c91e1a01596ab81c73 100644 --- a/gregui/tests/conftest.py +++ b/gregui/tests/conftest.py @@ -74,6 +74,12 @@ def unit_foo() -> OrganizationalUnit: return OrganizationalUnit.objects.get(id=ou.id) +@pytest.fixture +def unit_bar() -> OrganizationalUnit: + ou = OrganizationalUnit.objects.create(name_en="Bar EN", name_nb="Bar NB") + return OrganizationalUnit.objects.get(id=ou.id) + + @pytest.fixture def role_type_foo() -> RoleType: rt = RoleType.objects.create( @@ -118,9 +124,9 @@ def sponsor_foo( @pytest.fixture -def sponsor_bar(unit_foo: OrganizationalUnit, create_sponsor) -> Sponsor: +def sponsor_bar(unit_bar: OrganizationalUnit, create_sponsor) -> Sponsor: return create_sponsor( - feide_id="bar@example.com", first_name="Bar", last_name="Baz", unit=unit_foo + feide_id="bar@example.com", first_name="Bar", last_name="Baz", unit=unit_bar ) @@ -161,7 +167,7 @@ def user_sponsor(sponsor_foo: Sponsor, create_user) -> User: # Create a user and link him to a sponsor user = create_user( - username="test_sponsor", + username="test_sponsor_foo", email="test@example.org", first_name="Test", last_name="Sponsor", @@ -172,6 +178,23 @@ def user_sponsor(sponsor_foo: Sponsor, create_user) -> User: return user_model.objects.get(id=user.id) +@pytest.fixture +def user_sponsor_bar(sponsor_bar: Sponsor, create_user) -> User: + user_model = get_user_model() + + # Create a user and link him to a sponsor + user = create_user( + username="test_sponsor_bar", + email="test2@example.org", + first_name="Sponsor", + last_name="Bar", + ) + GregUserProfile.objects.create(user=user, sponsor=sponsor_bar) + + # This user is a sponsor for unit_foo + return user_model.objects.get(id=user.id) + + @pytest.fixture def user_person(person_foo: Sponsor, create_user) -> User: user_model = get_user_model() diff --git a/gregui/urls.py b/gregui/urls.py index 4292a73e4146722848d2b9f7d476f037bd99e87a..a8ff0fbd7bbb2b6dcd7449a5a48e36285ab51308 100644 --- a/gregui/urls.py +++ b/gregui/urls.py @@ -5,18 +5,9 @@ from django.urls import path from django.urls.resolvers import URLResolver from gregui.api import urls as api_urls -from gregui.api.views.userinfo import UserInfoView -from . import views urlpatterns: List[URLResolver] = [ path( "api/ui/v1/", include((api_urls.urlpatterns, "gregui"), namespace="gregui-v1") ), - path("api/ui/v1/csrf/", views.get_csrf, name="api-csrf"), - path("api/ui/v1/logout/", views.logout_view, name="api-logout"), - path("api/ui/v1/login/", views.login_view, name="api-login"), - path("api/ui/v1/session/", views.SessionView.as_view(), name="api-session"), - path("api/ui/v1/testmail/", views.send_test_email, name="api-testmail"), - path("api/ui/v1/whoami/", views.WhoAmIView.as_view(), name="api-whoami"), - path("api/ui/v1/userinfo/", UserInfoView.as_view(), name="api-userinfo"), # type: ignore ] diff --git a/gregui/views.py b/gregui/views.py deleted file mode 100644 index a60d3b760e25993ea4005b78fad4d6850fc472f9..0000000000000000000000000000000000000000 --- a/gregui/views.py +++ /dev/null @@ -1,60 +0,0 @@ -from django.contrib.auth import logout -from django.http import JsonResponse -from django.middleware.csrf import get_token -from django.shortcuts import redirect -from rest_framework.authentication import SessionAuthentication, BasicAuthentication -from rest_framework.permissions import IsAuthenticated -from rest_framework.views import APIView - -from gregui import mailutils - - -def get_csrf(request): - response = JsonResponse({"detail": "CSRF cookie set"}) - response["X-CSRFToken"] = get_token(request) - return response - - -def logout_view(request): - if not request.user.is_authenticated: - return JsonResponse({"detail": "You're not logged in."}, status=400) - - logout(request) - return JsonResponse({"detail": "Successfully logged out."}) - - -def login_view(request): - """ - View for pointing login links to - - Sesame will take the query string automatically and use it to create a session for - the user, so all this needs to do is redirect the user wherever they're supposed to - go after successfully logging in. - """ - # TODO: redirect to whatever path the frontend ends up living at (prob '/') - return redirect("/api/ui/v1/whoami/") - - -def send_test_email(request): - mailutils.send_registration_mail("test@example.no", "Foo Bar") - return JsonResponse({"detail": "Created task to send test mail."}) - - -class SessionView(APIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated] - - @staticmethod - # pylint: disable=W0622 - def get(request, format=None): - return JsonResponse({"isAuthenticated": True}) - - -class WhoAmIView(APIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated] - - @staticmethod - # pylint: disable=W0622 - def get(request, format=None): - return JsonResponse({"username": request.user.username})