diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 5ff11a78228ca0b41391eb9512252edfb4c459cb..ad3e60bf4a48f4680879a02d95555feb3028f70c 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -17,6 +17,8 @@ "roleStartDate": "From", "roleEndDate": "To", "comment": "Comment", + "contact": "Contact person", + "searchable": "Available in search?", "email": "E-mail", "fullName": "Full name", "mobilePhone": "Mobile phone", @@ -25,14 +27,21 @@ "countryCallingCode": "Country code" }, "sponsor": { - "contactInfo": "Contact information", - "roleInfo": "Guest role- and period information", + "addRole": "Add role", "roleInfoText": "You can change the start and end dates for the role.", "choose": "Choose", "details": "Details", "modifyEnd": "Change end date", - "endNow": "End role", - "overviewGuest": "Guest overview" + "endNow": "End role" + }, + "guestInfo": { + "contactInfo": "Contact information", + "roleInfoHead": "Roles and periods", + "roleInfoBody": "You can only change roles that you have given" + }, + "guest": { + "headerText": "Add new role and period.", + "bodyText": "Here you can add a new role to the same guest" }, "register": { "registerHeading": "Register new guest", diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index 4b2b4ae26ee9f88e176106e32ff593fa2bb02d76..41828b97a9f79695f3254220e07477295699658d 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -17,6 +17,8 @@ "roleStartDate": "Fra", "roleEndDate": "Til", "comment": "Kommentar", + "contact": "Kontaktperson", + "searchable": "Synlig i søk?", "email": "E-post", "fullName": "Fullt navn", "mobilePhone": "Mobilnummer", @@ -25,14 +27,21 @@ "countryCallingCode": "Landkode" }, "sponsor": { - "contactInfo": "Kontaktinformasjon", - "roleInfo": "Gjesterolle- og periodeinformasjon", + "addRole": "Legg til rolle", "roleInfoText": "Her kan du endre på start- og sluttdato for gjesterollen eller avslutte perioden", "choose": "Velg", "details": "Detaljer", "modifyEnd": "Endre sluttdato", - "endNow": "Avslutt rolle", - "overviewGuest": "Oversikt over gjest" + "endNow": "Avslutt rolle" + }, + "guestInfo": { + "contactInfo": "Kontaktinformasjon", + "roleInfoHead": "Roller og perioder", + "roleInfoBody": "Du kan bare endre på gjesteroller som du er vert for" + }, + "guest": { + "headerText": "Legg til ny rolle og periode", + "bodyText": "Her kan du legge til en ny rolle på samme gjest" }, "register": { "registerHeading": "Registrer ny gjest", diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index 23ff342f4192359ebd8760f491d4d24b3fd326ad..af1b8aac7174d127b2ae632e014d7c24eec139f4 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -18,6 +18,8 @@ "roleStartDate": "Frå", "roleEndDate": "Til", "comment": "Kommentar", + "contact": "Kontaktperson", + "searchable": "Synleg i søk?", "email": "E-post", "fullName": "Fullt namn", "mobilePhone": "Mobilnummer", @@ -26,14 +28,21 @@ "countryCallingCode": "Landkode" }, "sponsor": { - "contactInfo": "Kontaktinformasjon", - "roleInfo": "Gjesterolle- og periodeinformasjon", + "addRole": "Legg til role", "roleInfoText": "Her kan du endre på start- og sluttdato for gjesterollen eller avslutte perioden", "choose": "Velg", "details": "Detaljer", "modifyEnd": "Endre sluttdato", - "endNow": "Avslutt rolle", - "overviewGuest": "Oversikt over gjest" + "endNow": "Avslutt rolle" + }, + "guestInfo": { + "contactInfo": "Kontaktinformasjon", + "roleInfoHead": "Roller og perioder", + "roleInfoBody": "Du kan bare endre på gjesteroller som du er vert for" + }, + "guest": { + "headerText": "Legg til ny rolle og periode", + "bodyText": "Her kan du legge til en ny rolle på samme gjest" }, "register": { "registerHeading": "Registrer ny gjest", diff --git a/frontend/src/hooks/useRoleTypes/index.tsx b/frontend/src/hooks/useRoleTypes/index.tsx index 7e915decd861bba6c78ac923dd85a524c4717f89..0a61d01311038623c0769937dbbfc6e6703fc21c 100644 --- a/frontend/src/hooks/useRoleTypes/index.tsx +++ b/frontend/src/hooks/useRoleTypes/index.tsx @@ -5,6 +5,7 @@ type RoleTypeData = { identifier: string name_en: string name_nb: string + max_days: number } function useRoleTypes(): RoleTypeData[] { diff --git a/frontend/src/routes/components/sponsorInfoButtons.tsx b/frontend/src/routes/components/sponsorInfoButtons.tsx index 65c1d5e8ce44c8bd50d03be2f2d41162d63bd3b6..ce50eb18df2b7452bd7c5c2d215de70ec2bbad73 100644 --- a/frontend/src/routes/components/sponsorInfoButtons.tsx +++ b/frontend/src/routes/components/sponsorInfoButtons.tsx @@ -2,14 +2,16 @@ import { IconButton, Theme, Box } from '@mui/material' import { Link } from 'react-router-dom' import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded' import ArrowBackIcon from '@mui/icons-material/ArrowBack' -import { useTranslation } from 'react-i18next' interface SponsorInfoButtonsProps { to: string + name: string } -export default function SponsorInfoButtons({ to }: SponsorInfoButtonsProps) { - const { t } = useTranslation(['common']) +export default function SponsorInfoButtons({ + to, + name, +}: SponsorInfoButtonsProps) { return ( <Box sx={{ @@ -46,7 +48,7 @@ export default function SponsorInfoButtons({ to }: SponsorInfoButtonsProps) { typography: 'caption', }} > - {t('sponsor.overviewGuest')} + {name} </Box> </Box> </Box> diff --git a/frontend/src/routes/sponsor/guestInfo/index.tsx b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx similarity index 62% rename from frontend/src/routes/sponsor/guestInfo/index.tsx rename to frontend/src/routes/sponsor/guest/guestInfo/index.tsx index ea8dad32642d6d381082de3b8d5258c3aa4acfbf..139e128465ec003b0200749ad59cc37980185557 100644 --- a/frontend/src/routes/sponsor/guestInfo/index.tsx +++ b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx @@ -1,4 +1,3 @@ -import React, { useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' import Page from 'components/page' @@ -13,10 +12,9 @@ import { TableRow, Paper, } from '@mui/material' -import { Guest, Role, FetchedRole } from 'interfaces' +import { Guest, Role } from 'interfaces' import SponsorInfoButtons from 'routes/components/sponsorInfoButtons' import { format } from 'date-fns' -import { parseRole } from 'utils' type GuestInfoParams = { pid: string @@ -25,14 +23,15 @@ interface RoleLineProps { role: Role pid: string } +type GuestInfoProps = { + guest: Guest + roles: Role[] +} const RoleLine = ({ role, pid }: RoleLineProps) => { const [t, i18n] = useTranslation('common') return ( - <TableRow - key={role.id} - sx={{ '&:last-child td, &:last-child th': { border: 0 } }} - > + <TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> <TableCell align="left"> {i18n.language === 'en' ? role.name_en : role.name_nb} </TableCell> @@ -56,60 +55,19 @@ const RoleLine = ({ role, pid }: RoleLineProps) => { ) } -export default function GuestInfo() { +export default function GuestInfo({ guest, roles }: GuestInfoProps) { const { pid } = useParams<GuestInfoParams>() const [t] = useTranslation(['common']) - const [guestInfo, setGuest] = useState<Guest>({ - pid: '', - first: '', - last: '', - email: '', - fnr: '', - mobile: '', - active: false, - registered: false, - verified: false, - roles: [], - }) - const [roles, setRoles] = useState<Role[]>([]) - - const getPerson = async (id: string) => { - try { - const response = await fetch(`/api/ui/v1/person/${id}`) - const rjson = await response.json() - if (response.ok) { - setGuest({ - pid: rjson.pid, - first: rjson.first, - last: rjson.last, - email: rjson.email, - mobile: rjson.mobile, - fnr: rjson.fnr, - active: rjson.active, - registered: rjson.registered, - verified: rjson.verified, - roles: rjson.roles, - }) - setRoles(rjson.roles.map((role: FetchedRole) => parseRole(role))) - } - } catch (error) { - console.error(error) - } - } - - useEffect(() => { - getPerson(pid) - }, []) return ( <Page> - <SponsorInfoButtons to="/sponsor" /> - <h4>{t('sponsor.contactInfo')}</h4> + <SponsorInfoButtons to="/sponsor" name={`${guest.first} ${guest.last}`} /> + <h4>{t('guestInfo.contactInfo')}</h4> <TableContainer component={Paper}> <Table sx={{ minWidth: 650 }} aria-label="simple table"> <TableHead sx={{ backgroundColor: 'primary.light' }}> <TableRow> - <TableCell align="left">{t('sponsor.contactInfo')}</TableCell> + <TableCell align="left">{t('guestInfo.contactInfo')}</TableCell> <TableCell /> </TableRow> </TableHead> @@ -117,25 +75,36 @@ export default function GuestInfo() { <TableRow> <TableCell align="left">{t('input.fullName')}</TableCell> <TableCell align="left"> - {`${guestInfo.first} ${guestInfo.last}`} + {`${guest.first} ${guest.last}`} </TableCell> </TableRow> <TableRow> <TableCell align="left">{t('input.email')}</TableCell> - <TableCell align="left">{guestInfo.email}</TableCell> + <TableCell align="left">{guest.email}</TableCell> </TableRow> <TableRow> <TableCell align="left">{t('input.nationalIdNumber')}</TableCell> - <TableCell align="left">{guestInfo.fnr}</TableCell> + <TableCell align="left">{guest.fnr}</TableCell> </TableRow> <TableRow> <TableCell align="left">{t('input.mobilePhone')}</TableCell> - <TableCell align="left">{guestInfo.mobile}</TableCell> + <TableCell align="left">{guest.mobile}</TableCell> </TableRow> </TableBody> </Table> </TableContainer> - <h4>{t('sponsor.roleInfo')}</h4> + <h4>{t('guestInfo.roleInfoHead')}</h4> + <h5> + {t('guestInfo.roleInfoBody')} + <Button + variant="contained" + component={Link} + to={`/sponsor/guest/${pid}/newrole`} + > + {t('sponsor.addRole')} + </Button> + </h5> + <TableContainer component={Paper}> <Table sx={{ minWidth: 650 }} aria-label="simple table"> <TableHead sx={{ backgroundColor: 'primary.light' }}> @@ -148,7 +117,7 @@ export default function GuestInfo() { </TableHead> <TableBody> {roles.map((role) => ( - <RoleLine pid={pid} role={role} /> + <RoleLine key={role.id} pid={pid} role={role} /> ))} </TableBody> </Table> diff --git a/frontend/src/routes/sponsor/guestRoleInfo/index.tsx b/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx similarity index 90% rename from frontend/src/routes/sponsor/guestRoleInfo/index.tsx rename to frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx index fed60946e6e9cb66f1af5518f2e2f2d1582609d4..6445c4ac23c5778e3b2707eba9865ce5a389cf42 100644 --- a/frontend/src/routes/sponsor/guestRoleInfo/index.tsx +++ b/frontend/src/routes/sponsor/guest/guestRoleInfo/index.tsx @@ -12,16 +12,17 @@ import { TextField, } from '@mui/material' import Page from 'components/page' -import { Guest } from 'interfaces' +import { Guest, Role } from 'interfaces' import { useTranslation } from 'react-i18next' import { useParams } from 'react-router-dom' import SponsorInfoButtons from 'routes/components/sponsorInfoButtons' import { DatePicker } from '@mui/lab' import { Controller, SubmitHandler, useForm } from 'react-hook-form' -import { submitJsonOpts } from '../../../utils' +import { submitJsonOpts } from '../../../../utils' interface GuestRoleInfoProps { - guests: Guest[] + guest: Guest + roles: Role[] } const endPeriodPost = (id: string, data: { end_date: Date }) => { const payload = { @@ -56,15 +57,12 @@ type RoleFormData = { end_date: Date } -export default function GuestRoleInfo({ guests }: GuestRoleInfoProps) { +export default function GuestRoleInfo({ guest, roles }: GuestRoleInfoProps) { const { pid, id } = useParams<GuestRoleInfoParams>() const [t, i18n] = useTranslation('common') // Find the role info relevant for this page - const guestInfo = guests.filter((guest) => guest.pid.toString() === pid)[0] - const roleInfo = guestInfo.roles.filter( - (role) => role.id.toString() === id - )[0] + const roleInfo = roles.filter((role) => role.id.toString() === id)[0] // Prepare min and max date values const today = new Date() @@ -106,10 +104,12 @@ export default function GuestRoleInfo({ guests }: GuestRoleInfoProps) { const { control, handleSubmit } = useForm() const onSubmit = handleSubmit(submit) - return ( <Page> - <SponsorInfoButtons to={`/sponsor/guest/${pid}`} /> + <SponsorInfoButtons + to={`/sponsor/guest/${pid}`} + name={`${guest.first} ${guest.last}`} + /> <h4>{t('sponsor.roleInfoText')}</h4> <form onSubmit={onSubmit}> <TableContainer component={Paper}> @@ -139,9 +139,7 @@ export default function GuestRoleInfo({ guests }: GuestRoleInfoProps) { render={({ field: { onChange, value } }) => ( <DatePicker mask="____-__-__" - disabled={ - roleInfo.start_date.getDate() <= today.getDate() - } + disabled={roleInfo.start_date <= today} label={t('input.roleStartDate')} value={value} minDate={today} @@ -162,7 +160,7 @@ export default function GuestRoleInfo({ guests }: GuestRoleInfoProps) { <DatePicker mask="____-__-__" label={t('input.roleEndDate')} - disabled={roleInfo.end_date.getDate() < today.getDate()} + disabled={roleInfo.end_date < today} minDate={today} maxDate={todayPlusMaxDays} value={value} diff --git a/frontend/src/routes/sponsor/guest/index.tsx b/frontend/src/routes/sponsor/guest/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..81152184d3bc670acc3b3ffed86b7dedc8972310 --- /dev/null +++ b/frontend/src/routes/sponsor/guest/index.tsx @@ -0,0 +1,73 @@ +import { FetchedRole, Guest, Role } from 'interfaces' +import { useEffect, useState } from 'react' +import { Route, useParams } from 'react-router-dom' +import { parseRole } from 'utils' +import GuestInfo from './guestInfo' +import GuestRoleInfo from './guestRoleInfo' +import NewGuestRole from './newGuestRole' + +type GuestInfoParams = { + pid: string +} + +function GuestRoutes() { + const { pid } = useParams<GuestInfoParams>() + + const [guestInfo, setGuest] = useState<Guest>({ + pid: '', + first: '', + last: '', + email: '', + fnr: '', + mobile: '', + active: false, + registered: false, + verified: false, + roles: [], + }) + const [roles, setRoles] = useState<Role[]>([]) + + const getPerson = async (id: string) => { + try { + const response = await fetch(`/api/ui/v1/person/${id}`) + const rjson = await response.json() + if (response.ok) { + setGuest({ + pid: rjson.pid, + first: rjson.first, + last: rjson.last, + email: rjson.email, + mobile: rjson.mobile, + fnr: rjson.fnr, + active: rjson.active, + registered: rjson.registered, + verified: rjson.verified, + roles: rjson.roles, + }) + setRoles(rjson.roles.map((role: FetchedRole) => parseRole(role))) + } + } catch (error) { + console.error(error) + } + } + + useEffect(() => { + getPerson(pid) + }, []) + + return ( + <> + <Route path="/sponsor/guest/:pid/roles/:id"> + <GuestRoleInfo guest={guestInfo} roles={roles} /> + </Route> + <Route exact path="/sponsor/guest/:pid/newrole"> + <NewGuestRole guest={guestInfo} /> + </Route> + <Route exact path="/sponsor/guest/:pid"> + <GuestInfo guest={guestInfo} roles={roles} /> + </Route> + </> + ) +} + +export default GuestRoutes diff --git a/frontend/src/routes/sponsor/guest/newGuestRole/index.tsx b/frontend/src/routes/sponsor/guest/newGuestRole/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2cf41c37ebcf0651aec1033c21fde36c3f42f28b --- /dev/null +++ b/frontend/src/routes/sponsor/guest/newGuestRole/index.tsx @@ -0,0 +1,274 @@ +import { DatePicker } from '@mui/lab' +import { addDays } from 'date-fns/fp' +import { + Checkbox, + Button, + Select, + FormControl, + InputLabel, + MenuItem, + Stack, + TextField, + SelectChangeEvent, + FormControlLabel, +} from '@mui/material' +import Page from 'components/page' +import { format } from 'date-fns' +import useOus, { enSort, nbSort, OuData } from 'hooks/useOus' +import useRoleTypes, { RoleTypeData } from 'hooks/useRoleTypes' +import { Guest } from 'interfaces' +import { useState } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { Link, useParams } from 'react-router-dom' +import SponsorInfoButtons from 'routes/components/sponsorInfoButtons' +import { submitJsonOpts } from 'utils' + +type AddRoleFormData = { + orgunit: number + type: string + end_date: Date + start_date?: Date + contact_person_unit?: string + comments?: string + available_in_search?: boolean +} +type AddRolePayload = { + orgunit: number + person: string + type: string + end_date: string + start_date?: string + contact_person_unit?: string + comments?: string + available_in_search?: boolean +} + +type GuestInfoParams = { + pid: string +} + +interface NewGuestRoleProps { + guest: Guest +} + +const postRole = (formData: AddRoleFormData, pid: string) => { + const payload: AddRolePayload = { + orgunit: formData.orgunit, + person: pid, + type: formData.type, + end_date: format(formData.end_date as Date, 'yyyy-MM-dd'), + } + if (formData.start_date) { + payload.start_date = format(formData.start_date as Date, 'yyyy-MM-dd') + } + if (formData.contact_person_unit) { + payload.contact_person_unit = formData.contact_person_unit + } + if (formData.comments) { + payload.comments = formData.comments + } + if (formData.available_in_search) { + payload.available_in_search = formData.available_in_search + } + + console.log('submitting', JSON.stringify(payload)) + fetch('/api/ui/v1/role', submitJsonOpts('POST', payload)) + .then((res) => { + if (!res.ok) { + console.log('result', res) + return null + } + console.log('result', res) + return res.text() + }) + .then((result) => { + if (result !== null) { + console.log('result', result) + } + }) + .catch((error) => { + console.log('error', error) + }) +} + +function NewGuestRole({ guest }: NewGuestRoleProps) { + const { + register, + control, + handleSubmit, + formState: { errors }, + setValue, + getValues, + } = useForm<AddRoleFormData>() + + const { pid } = useParams<GuestInfoParams>() + const onSubmit = handleSubmit(() => { + postRole(getValues(), pid) + }) + + const ous = useOus() + const roleTypes = useRoleTypes() + const [ouChoice, setOuChoice] = useState<string>('') + const [roleTypeChoice, setRoleTypeChoice] = useState<string>('') + const [t, i18n] = useTranslation('common') + const today = new Date() + + const todayPlusMaxDays = () => { + if (roleTypeChoice) { + const role = roleTypes.filter( + (rt) => rt.id.toString() === roleTypeChoice.toString() + )[0] + return addDays(role.max_days)(today) + } + return addDays(0)(today) + } + + const roleTypeSort = () => (a: RoleTypeData, b: RoleTypeData) => { + if (i18n.language === 'en') { + return a.name_nb.localeCompare(b.name_nb) + } + return a.name_en.localeCompare(b.name_en) + } + // Handling choices in menus + const handleRoleTypeChange = (event: SelectChangeEvent) => { + setValue('type', event.target.value) + setRoleTypeChoice(event.target.value) + } + const handleOuChange = (event: SelectChangeEvent) => { + if (event.target.value) { + setOuChoice(event.target.value) + setValue('orgunit', parseInt(event.target.value, 10)) + } + } + // Functions for menu items + const rolesToItem = (roleType: RoleTypeData) => ( + <MenuItem key={roleType.id.toString()} value={roleType.id}> + {i18n.language === 'en' ? roleType.name_en : roleType.name_nb} + </MenuItem> + ) + const ouToItem = (ou: OuData) => ( + <MenuItem key={ou.id.toString()} value={ou.id}> + {i18n.language === 'en' ? ou.en : ou.nb} ({ou.id}) + </MenuItem> + ) + + return ( + <Page> + <SponsorInfoButtons + to={`/sponsor/guest/${pid}`} + name={`${guest.first} ${guest.last}`} + /> + <h3>{t('guest.headerText')}</h3> + <h4>{t('guest.bodyText')}</h4> + <form onSubmit={onSubmit}> + <Stack spacing={2}> + <FormControl> + <InputLabel id="ou-select-label">{t('input.roleType')}</InputLabel> + <Select + id="roletype-select" + defaultValue="" + value={roleTypeChoice} + error={!!errors.type} + label={t('input.roleType')} + onChange={handleRoleTypeChange} + > + {roleTypes.sort(roleTypeSort()).map((rt) => rolesToItem(rt))} + </Select> + </FormControl> + + <FormControl> + <InputLabel id="ou-select-label">{t('common:ou')}</InputLabel> + <Select + labelId="ou-select-label" + id="ou-select-label" + defaultValue="" + value={ouChoice.toString()} + label={t('common:ou')} + onChange={handleOuChange} + > + {ous.length > 0 ? ( + ous + .sort(i18n.language === 'en' ? enSort : nbSort) + .map((ou) => ouToItem(ou)) + ) : ( + <></> + )} + </Select> + </FormControl> + <Controller + name="start_date" + control={control} + defaultValue={today} + render={({ field }) => ( + <DatePicker + mask="____-__-__" + label={t('input.roleStartDate')} + disabled={!roleTypeChoice} + value={field.value} + minDate={today} + maxDate={todayPlusMaxDays()} + inputFormat="yyyy-MM-dd" + onChange={(value) => { + field.onChange(value) + }} + renderInput={(params) => <TextField {...params} />} + /> + )} + /> + <Controller + name="end_date" + control={control} + defaultValue={today} + render={({ field }) => ( + <DatePicker + mask="____-__-__" + label={t('input.roleEndDate')} + disabled={!roleTypeChoice} + value={field.value} + minDate={today} + maxDate={todayPlusMaxDays()} + inputFormat="yyyy-MM-dd" + onChange={(value) => { + field.onChange(value) + }} + renderInput={(params) => <TextField {...params} />} + /> + )} + /> + + <TextField + id="contact" + label={t('input.contact')} + multiline + rows={5} + {...register('contact_person_unit')} + /> + <TextField + id="comments" + label={t('input.comment')} + multiline + rows={5} + {...register('comments')} + /> + <FormControlLabel + control={ + <Checkbox + id="available_in_search" + {...register('available_in_search')} + /> + } + label={t('input.searchable')} + /> + <Button variant="contained" type="submit"> + {t('button.save')} + </Button> + <Button component={Link} to={`/sponsor/guest/${pid}`}> + {t('button.cancel')} + </Button> + </Stack> + </form> + </Page> + ) +} +export default NewGuestRole diff --git a/frontend/src/routes/sponsor/index.tsx b/frontend/src/routes/sponsor/index.tsx index 1df6b21dc7f462e8dfc369de0f765c732191ab89..d070e2b28d5d4cef3fb69234c66d4c6335ebd40f 100644 --- a/frontend/src/routes/sponsor/index.tsx +++ b/frontend/src/routes/sponsor/index.tsx @@ -1,11 +1,10 @@ -import React, { useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { Route } from 'react-router-dom' import FrontPage from 'routes/sponsor/frontpage' -import GuestInfo from 'routes/sponsor/guestInfo' -import GuestRoleInfo from 'routes/sponsor/guestRoleInfo' import { FetchedGuest, Guest } from 'interfaces' import { parseRole } from 'utils' +import GuestRoutes from './guest' function Sponsor() { const [guests, setGuests] = useState<Guest[]>([]) @@ -44,11 +43,8 @@ function Sponsor() { return ( <> - <Route path="/sponsor/guest/:pid/roles/:id"> - <GuestRoleInfo guests={guests} /> - </Route> - <Route exact path="/sponsor/guest/:pid"> - <GuestInfo /> + <Route path="/sponsor/guest/:pid"> + <GuestRoutes /> </Route> <Route exact path="/sponsor"> <FrontPage guests={guests} /> diff --git a/gregui/api/serializers/invitation.py b/gregui/api/serializers/invitation.py index c526a986d6dbc26903637f115db046659ae1fac9..18aa892677febf3360f76e90bdd1e70460ed2dca 100644 --- a/gregui/api/serializers/invitation.py +++ b/gregui/api/serializers/invitation.py @@ -5,13 +5,13 @@ from django.utils import timezone from rest_framework import serializers from greg.models import Invitation, InvitationLink, Person, Role, Identity -from gregui.api.serializers.role import RoleSerializerUi +from gregui.api.serializers.role import InviteRoleSerializerUi from gregui.models import GregUserProfile class InviteGuestSerializer(serializers.ModelSerializer): email = serializers.EmailField(required=True) - role = RoleSerializerUi(required=True) + role = InviteRoleSerializerUi(required=True) uuid = serializers.UUIDField(read_only=True) def create(self, validated_data): diff --git a/gregui/api/serializers/role.py b/gregui/api/serializers/role.py index f9791220ed2f7ebc008e776e710e27afa647ca52..722c8e8f4a71f8387d525093d766c778263b8b60 100644 --- a/gregui/api/serializers/role.py +++ b/gregui/api/serializers/role.py @@ -1,44 +1,99 @@ import datetime from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from rest_framework.validators import UniqueTogetherValidator from greg.models import Role class RoleSerializerUi(serializers.ModelSerializer): - def update(self, instance, validated_data): - # validate new date is after old date, and old date has not passed + """Serializer for the Role model with validation of various fields""" + + class Meta: + model = Role + fields = [ + "orgunit", + "start_date", + "type", + "end_date", + "contact_person_unit", + "comments", + "available_in_search", + "person", + ] + validators = [ + UniqueTogetherValidator( + queryset=Role.objects.all(), + fields=["person", "type", "orgunit", "start_date", "end_date"], + ) + ] + + def validate_start_date(self, start_date): today = datetime.date.today() - new_start = validated_data.get("start_date") - new_end = validated_data.get("end_date") - if new_start: - new_start = datetime.datetime.strptime( - validated_data["start_date"], "%Y-%m-%d" - ).date() - if new_start < today: - raise serializers.ValidationError("Start date cannot be in the past") - if instance.start_date < today and new_start: - raise serializers.ValidationError( - "Role has started, cannot change start date" - ) - instance.start_date = new_start - if new_end: - new_end = datetime.datetime.strptime( - validated_data["end_date"], "%Y-%m-%d" - ).date() - if new_end < today: - raise serializers.ValidationError("End date cannot be in the past") - if instance.end_date < today and new_end: + # 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." + ) + # If we are updating an existing roles, we must be the sponsor of the role + if self.instance and self.instance.sponsor != sponsor: + raise ValidationError("You can only edit your own roles.") + return unit + + def validate(self, attrs): + """Validate things that need access to multiple fields""" + # Ensure end date is not further into the future than the role type allows + today = datetime.date.today() + if self.instance: + max_days = today + datetime.timedelta(days=self.instance.type.max_days) + else: + max_days = today + datetime.timedelta(days=attrs["type"].max_days) + if attrs["end_date"] > max_days: + raise serializers.ValidationError( + f"New end date too far into the future for this type. Must be before {max_days.strftime('%Y-%m-%d')}" + ) + # Ensure end date is after start date if start date is set + if self.instance: + start_date = attrs.get("start_date") or self.instance.start_date + end_date = attrs.get("end_date") or self.instance.end_date + if start_date and end_date < start_date: raise serializers.ValidationError( - "Role has ended, cannot change end date" + "End date cannot be before start date." ) - max_days = today + datetime.timedelta(days=instance.type.max_days) - if new_end > max_days: + else: + if attrs.get("start_date") and attrs["end_date"] < attrs["start_date"]: raise serializers.ValidationError( - f"New end date too far into the future. Must be before {max_days.strftime('%Y-%m-%d')}" + "End date cannot be before start date." ) - instance.end_date = new_end + return attrs + + +class InviteRoleSerializerUi(RoleSerializerUi): + """ + Serializer for the role part of an invite. - return instance + This one exists so that we don't have to specify the person argument on the role. + Simply reuses all the logic of the parent class except requiring the person field. + """ class Meta: model = Role diff --git a/gregui/api/serializers/roletype.py b/gregui/api/serializers/roletype.py index 5f1857d69d2ceb8a9e980d9041a99a56386c7ed5..d2adecea1231870408f67ac3d48631f5e4215322 100644 --- a/gregui/api/serializers/roletype.py +++ b/gregui/api/serializers/roletype.py @@ -6,4 +6,4 @@ from greg.models import RoleType class RoleTypeSerializerUi(ModelSerializer): class Meta: model = RoleType - fields = ("id", "identifier", "name_en", "name_nb") + fields = ("id", "identifier", "name_en", "name_nb", "max_days") diff --git a/gregui/api/urls.py b/gregui/api/urls.py index db1d56dbedfee7dafec148cde4f959575ef5d864..af6ff1ffdd2ee35a2284878d3147d0fc77fe1b2a 100644 --- a/gregui/api/urls.py +++ b/gregui/api/urls.py @@ -8,10 +8,12 @@ from gregui.api.views.invitation import ( InvitedGuestView, ) from gregui.api.views.person import PersonSearchView, PersonView +from gregui.api.views.role import RoleInfoViewSet from gregui.api.views.roletypes import RoleTypeViewSet from gregui.api.views.unit import UnitsViewSet router = DefaultRouter(trailing_slash=False) +router.register(r"role", RoleInfoViewSet, basename="role") urlpatterns = router.urls urlpatterns += [ diff --git a/gregui/api/views/person.py b/gregui/api/views/person.py index 79326f42d1ffc0f57e33113d3858fd9df41cc187..e5953a28e325c22f537781295d4d8c813010d4d5 100644 --- a/gregui/api/views/person.py +++ b/gregui/api/views/person.py @@ -29,6 +29,9 @@ class PersonView(APIView): "email": person.private_email and person.private_email.value, "mobile": person.private_mobile and person.private_mobile.value, "fnr": person.fnr and "".join((person.fnr.value[:-5], "*****")), + "active": person.is_registered and person.is_verified, + "registered": person.is_registered, + "verified": person.is_verified, "roles": [ { "id": role.id, diff --git a/gregui/api/views/role.py b/gregui/api/views/role.py index 47b2fbf959b557c2bbebd85425050bd8d4281966..7424b2858c5d60b845c3d5eb8146491cd41a7e74 100644 --- a/gregui/api/views/role.py +++ b/gregui/api/views/role.py @@ -1,30 +1,46 @@ from django.db import transaction -from rest_framework import status +from rest_framework import serializers, status from rest_framework.authentication import BasicAuthentication, SessionAuthentication -from rest_framework.generics import GenericAPIView +from rest_framework.viewsets import ModelViewSet from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from greg.models import Role from greg.permissions import IsSponsor from gregui.api.serializers.role import RoleSerializerUi -from gregui.api.serializers.roletype import RoleTypeSerializerUi +from gregui.models import GregUserProfile -class RoleInfoView(GenericAPIView): +class RoleInfoViewSet(ModelViewSet): queryset = Role.objects.all() authentication_classes = [SessionAuthentication, BasicAuthentication] permission_classes = [IsAuthenticated, IsSponsor] serializer_class = RoleSerializerUi - def patch(self, request, id): - role = Role.objects.get(id=id) - + def partial_update(self, request, pk): + role = Role.objects.get(pk=pk) + sponsor = GregUserProfile.objects.get(user=self.request.user).sponsor with transaction.atomic(): serializer = self.serializer_class( - instance=role, data=request.data, partial=True + instance=role, + data=request.data, + partial=True, + context={"sponsor": sponsor}, ) serializer.is_valid(raise_exception=True) - instance = serializer.update(role, request.data) + instance = serializer.update(role, serializer.validated_data) instance.save() return Response(status=status.HTTP_200_OK) + + def create(self, request): + sponsor = GregUserProfile.objects.get(user=self.request.user).sponsor + with transaction.atomic(): + serializer = self.serializer_class( + data=request.data, + context={ + "sponsor": sponsor, + }, + ) + serializer.is_valid(raise_exception=True) + serializer.save(sponsor=sponsor) + return Response(status=status.HTTP_201_CREATED) diff --git a/gregui/tests/api/test_invite_guest.py b/gregui/tests/api/test_invite_guest.py index 46f8b10821d499a69c1ea781a47de18e166b1310..87554ec7edbb03b6325945abc06e478e469ba6dc 100644 --- a/gregui/tests/api/test_invite_guest.py +++ b/gregui/tests/api/test_invite_guest.py @@ -1,3 +1,4 @@ +import datetime import pytest from rest_framework import status from rest_framework.reverse import reverse @@ -14,8 +15,12 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo): "last_name": "Bar", "email": "test@example.com", "role": { - "start_date": "2019-08-06", - "end_date": "2019-08-10", + "start_date": ( + datetime.datetime.today() + datetime.timedelta(days=1) + ).strftime("%Y-%m-%d"), + "end_date": ( + datetime.datetime.today() + datetime.timedelta(days=10) + ).strftime("%Y-%m-%d"), "orgunit": unit_foo.id, "type": role_type_foo.id, }, diff --git a/gregui/urls.py b/gregui/urls.py index 5ef2297a90af140a512a0917078599f447da4e49..cc6fa884e2da3bc3a1884dcb83b4382a0f0ac9f9 100644 --- a/gregui/urls.py +++ b/gregui/urls.py @@ -5,7 +5,6 @@ from django.urls import path from django.urls.resolvers import URLResolver from gregui.api import urls as api_urls -from gregui.api.views.role import RoleInfoView from gregui.api.views.userinfo import UserInfoView from gregui.views import OusView, GuestInfoView from . import views @@ -23,5 +22,4 @@ urlpatterns: List[URLResolver] = [ path("api/ui/v1/userinfo/", UserInfoView.as_view()), # type: ignore path("api/ui/v1/ous/", OusView.as_view()), path("api/ui/v1/guests/", GuestInfoView.as_view()), - path("api/ui/v1/role/<int:id>", RoleInfoView.as_view()), ]