From 701afd23e0b9df1d0acad8b7a166e272aa402b4d Mon Sep 17 00:00:00 2001 From: Andreas Ellewsen <ae@uio.no> Date: Wed, 27 Oct 2021 10:51:03 +0200 Subject: [PATCH] Add role adding page to guest profiles Code for guest routes moved to their own folder so that we don't need multiple api calls. Serializer class for roles has been simplified with methods for validation of each field and a separate one used in invites without requiring the person field. Resolves: GREG-61 --- frontend/public/locales/en/common.json | 17 +- frontend/public/locales/nb/common.json | 17 +- frontend/public/locales/nn/common.json | 17 +- frontend/src/hooks/useRoleTypes/index.tsx | 1 + .../routes/components/sponsorInfoButtons.tsx | 10 +- .../sponsor/{ => guest}/guestInfo/index.tsx | 85 ++---- .../{ => guest}/guestRoleInfo/index.tsx | 26 +- frontend/src/routes/sponsor/guest/index.tsx | 73 +++++ .../sponsor/guest/newGuestRole/index.tsx | 274 ++++++++++++++++++ frontend/src/routes/sponsor/index.tsx | 12 +- gregui/api/serializers/invitation.py | 4 +- gregui/api/serializers/role.py | 111 +++++-- gregui/api/serializers/roletype.py | 2 +- gregui/api/urls.py | 2 + gregui/api/views/person.py | 3 + gregui/api/views/role.py | 34 ++- gregui/tests/api/test_invite_guest.py | 9 +- gregui/urls.py | 2 - 18 files changed, 559 insertions(+), 140 deletions(-) rename frontend/src/routes/sponsor/{ => guest}/guestInfo/index.tsx (62%) rename frontend/src/routes/sponsor/{ => guest}/guestRoleInfo/index.tsx (90%) create mode 100644 frontend/src/routes/sponsor/guest/index.tsx create mode 100644 frontend/src/routes/sponsor/guest/newGuestRole/index.tsx diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 5ff11a78..ad3e60bf 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 4b2b4ae2..41828b97 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 23ff342f..af1b8aac 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 7e915dec..0a61d013 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 65c1d5e8..ce50eb18 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 ea8dad32..139e1284 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 fed60946..6445c4ac 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 00000000..81152184 --- /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 00000000..2cf41c37 --- /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 1df6b21d..d070e2b2 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 c526a986..18aa8926 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 f9791220..722c8e8f 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 5f1857d6..d2adecea 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 db1d56db..af6ff1ff 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 79326f42..e5953a28 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 47b2fbf9..7424b285 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 46f8b108..87554ec7 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 5ef2297a..cc6fa884 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()), ] -- GitLab