diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index b9adc25046857afec4bad67e76932c59a9cf43d7..c1f7e439edae23e44ed6a45de1e1c0fae96c4ad9 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -99,10 +99,13 @@ "nationalIdOrPassport": "National ID or passport information need to be entered" }, "button": { + "yes": "Yes", + "no": "No", "back": "Back", "next": "Next", "save": "Save", "cancel": "Cancel", + "verify": "Verify", "backToFrontPage": "Go to front page", "cancelInvitation": "Cancel", "resendInvitation": "Send", @@ -136,6 +139,8 @@ "sponsorSubmitSuccessDescription": "Your registration has been completed. You will receive an e-mail when the guest has filled in the missing information, so that the guest account can be approved.", "guestSubmitSuccessDescription": "Your registration is now completed. You will receive an e-mail or SMS when your account has been created.", "confirmationDialog": { + "confirmIdentityText": "Confirm?", + "confirmIdentityTitle": "Are you sure you want to verify this identity?", "cancelInvitation": "Cancel invitation?", "cancelInvitationDescription": "Do you want to cancel the invitation?" } diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index f9172a826454e4ca3ad311af0c0cb9ef5b8aae31..64ac355125d35d8c40de428d91df23a59ca8e5ce 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -98,10 +98,13 @@ "nationalIdOrPassport": "Fødselsnummer eller passinformasjon må oppgis" }, "button": { + "yes": "Ja", + "no": "Nei", "back": "Tilbake", "next": "Neste", "save": "Lagre", "cancel": "Avbryt", + "verify": "Bekreft", "backToFrontPage": "Tilbake til forsiden", "resendInvitation": "Send", "cancelInvitation": "Kanseller", @@ -135,6 +138,8 @@ "sponsorSubmitSuccessDescription": "Din registrering er nå fullført. Du vil få en e-post når gjesten har fylt inn informasjonen som mangler, slik at gjestekontoen kan godkjennes.", "guestSubmitSuccessDescription": "Din registrering er nå fullført. Du vil få en e-post eller SMS når kontoen er opprettet.", "confirmationDialog": { + "confirmIdentityText": "Bekrefte?", + "confirmIdentityTitle": "Er du sikker på at du vil bekrefte denne identiteten?", "cancelInvitation": "Kanseller invitasjon?", "cancelInvitationDescription": "Vil du kansellere invitasjonen?" } diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index 8f13f7c9a04acca248f6ca85251b8b7b26bc97c8..6000ca6ee8d12388338fda29ebddcc6129a9ead7 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -99,10 +99,13 @@ "nationalIdOrPassport": "Fødselsnummer eller passinformasjon må oppgjevast" }, "button": { + "yes": "Ja", + "no": "Nei", "back": "Tilbake", "next": "Neste", "save": "Lagre", "cancel": "Avbryt", + "verify": "Bekreft", "backToFrontPage": "Tilbake til forsida", "resendInvitation": "Send", "cancelInvitation": "Kanseller", @@ -136,6 +139,8 @@ "sponsorSubmitSuccessDescription": "Di registrering er no fullført. Du vil få ein e-post når gjesten har fylt inn informasjonen som manglar, slik at gjestekontoen kan godkjennast.", "guestSubmitSuccessDescription": "Di registrering er no fullført. Du vil få ein e-post eller SMS når kontoen er oppretta.", "confirmationDialog": { + "confirmIdentityText": "Bekrefte?", + "confirmIdentityTitle": "Er du sikker på at du vil bekrefte denne identiteten?", "cancelInvitation": "Kanseller invitasjon?", "cancelInvitationDescription": "Vil du kansellere invitasjonen?" } diff --git a/frontend/src/components/confirmDialog/index.tsx b/frontend/src/components/confirmDialog/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4540e3661a8aba7c35ed8aea0fe83baaea26e937 --- /dev/null +++ b/frontend/src/components/confirmDialog/index.tsx @@ -0,0 +1,52 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material' +import React from 'react' +import { useTranslation } from 'react-i18next' + +type ConfirmDialogProps = { + title: string + open: boolean + setOpen: (value: boolean) => void + onConfirm: () => void + children: React.ReactNode +} + +const ConfirmDialog = (props: ConfirmDialogProps) => { + const { title, children, open, setOpen, onConfirm } = props + const [t] = useTranslation('common') + return ( + <Dialog + open={open} + onClose={() => setOpen(false)} + aria-labelledby="confirm-dialog" + > + <DialogTitle id="confirm-dialog">{title}</DialogTitle> + <DialogContent>{children}</DialogContent> + <DialogActions> + <Button + variant="contained" + onClick={() => setOpen(false)} + color="secondary" + > + {t('button.no')} + </Button> + <Button + variant="contained" + onClick={() => { + setOpen(false) + onConfirm() + }} + color="error" + > + {t('button.yes')} + </Button> + </DialogActions> + </Dialog> + ) +} +export default ConfirmDialog diff --git a/frontend/src/components/identityLine/index.tsx b/frontend/src/components/identityLine/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..54fbb24b2a7f1ed9addaed8e977f597480d36c9f --- /dev/null +++ b/frontend/src/components/identityLine/index.tsx @@ -0,0 +1,57 @@ +import { Button, TableCell, TableRow } from '@mui/material' +import ConfirmDialog from 'components/confirmDialog' +import { Identity } from 'interfaces' +import { useState } from 'react' +import { submitJsonOpts } from 'utils' +import CheckIcon from '@mui/icons-material/Check' +import { useTranslation } from 'react-i18next' + +interface IdentityLineProps { + text: string + identity: Identity | null + reloadGuest: () => void +} +const IdentityLine = ({ text, identity, reloadGuest }: IdentityLineProps) => { + // Make a line with a confirmation button if the identity has not been verified + const [confirmOpen, setConfirmOpen] = useState(false) + const [t] = useTranslation('common') + const verifyIdentity = (id: string) => async () => { + await fetch(`/api/ui/v1/identity/${id}`, submitJsonOpts('PATCH', {})) + reloadGuest() + } + + if (identity == null) { + return <></> + } + return ( + <TableRow> + <TableCell align="left">{text}</TableCell> + <TableCell align="left">{identity ? identity.value : ''}</TableCell> + <TableCell> + {!identity.verified_at ? ( + <div> + <Button + aria-label="verify" + onClick={() => setConfirmOpen(true)} + disabled={!identity} + > + {t('button.verify')} + </Button> + <ConfirmDialog + title={t('confirmationDialog.confirmIdentityTitle')} + open={confirmOpen} + setOpen={setConfirmOpen} + onConfirm={verifyIdentity(identity.id)} + > + {t('confirmationDialog.confirmIdentityText')} + </ConfirmDialog> + </div> + ) : ( + <CheckIcon sx={{ fill: (theme) => theme.palette.success.main }} /> + )} + </TableCell> + </TableRow> + ) +} + +export default IdentityLine diff --git a/frontend/src/hooks/useGuest/index.tsx b/frontend/src/hooks/useGuest/index.tsx index b719ca0a3aec9073e5e6d556c8090ddd70af30b6..62cf6e5b20f101c275c5cf478021f419611ddb45 100644 --- a/frontend/src/hooks/useGuest/index.tsx +++ b/frontend/src/hooks/useGuest/index.tsx @@ -1,6 +1,6 @@ import { FetchedRole, Guest } from 'interfaces' import { useEffect, useState } from 'react' -import { parseRole } from 'utils' +import { parseRole, parseIdentity } from 'utils' const useGuest = (pid: string) => { const [guestInfo, setGuest] = useState<Guest>({ @@ -8,7 +8,8 @@ const useGuest = (pid: string) => { first: '', last: '', email: '', - fnr: '', + fnr: null, + passport: null, mobile: '', active: false, registered: false, @@ -28,7 +29,8 @@ const useGuest = (pid: string) => { last: rjson.last, email: rjson.email, mobile: rjson.mobile, - fnr: rjson.fnr, + fnr: parseIdentity(rjson.fnr), + passport: parseIdentity(rjson.passport), active: rjson.active, registered: rjson.registered, verified: rjson.verified, diff --git a/frontend/src/hooks/useGuests/index.tsx b/frontend/src/hooks/useGuests/index.tsx index d18b246546fb848b0a2ad5641e09fd865f674343..04e62653ac34b8b3b2a8882899d508db454fac05 100644 --- a/frontend/src/hooks/useGuests/index.tsx +++ b/frontend/src/hooks/useGuests/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { FetchedGuest, Guest } from '../../interfaces' -import { parseRole } from '../../utils' +import { FetchedGuest, Guest } from 'interfaces' +import { parseIdentity, parseRole } from 'utils' const useGuests = () => { const [guests, setGuests] = useState<Guest[]>([]) @@ -10,7 +10,7 @@ const useGuests = () => { const response = await fetch('/api/ui/v1/guests/?format=json') const jsonResponse = await response.json() if (response.ok) { - const persons = await jsonResponse.persons + const persons = await jsonResponse setGuests( persons.map( (person: FetchedGuest): Guest => ({ @@ -19,7 +19,8 @@ const useGuests = () => { last: person.last, email: person.email, mobile: person.mobile, - fnr: person.fnr, + fnr: parseIdentity(person.fnr), + passport: parseIdentity(person.passport), active: person.active, roles: person.roles.map((role) => parseRole(role)), registered: person.registered, diff --git a/frontend/src/hooks/useOus/index.tsx b/frontend/src/hooks/useOus/index.tsx index 01459176acb55d3eeada3c9271b7ffc640d6b0b8..b8073e4e14c1263f6f5858712e41b1a72f433458 100644 --- a/frontend/src/hooks/useOus/index.tsx +++ b/frontend/src/hooks/useOus/index.tsx @@ -9,10 +9,10 @@ type OuData = { function useOus(): OuData[] { const [ous, setOus] = useState<OuData[]>([]) const getOptions = async () => { - const response = await fetch('/api/ui/v1/ous/?format=json') + const response = await fetch('/api/ui/v1/ous?format=json') if (response.ok) { const ousJson = await response.json() - setOus(ousJson.ous) + setOus(ousJson) } } diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 242b67852addc2434756b1113697b20975126120..9cd159ff4da87bee6e6eaa6a7544c8ad98e04bb5 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -4,20 +4,36 @@ export type Guest = { last: string email: string mobile: string - fnr: string + fnr: Identity | null + passport: Identity | null active: boolean registered: boolean verified: boolean roles: Role[] } +export type Identity = { + id: string + type: string + verified_at: Date | null + value: string +} + +export type FetchedIdentity = { + id: string + type: string + verified_at: string | null + value: string +} + export interface FetchedGuest { pid: string first: string last: string email: string mobile: string - fnr: string + fnr: FetchedIdentity | null + passport: FetchedIdentity | null active: boolean registered: boolean verified: boolean diff --git a/frontend/src/routes/sponsor/guest/guestInfo/index.tsx b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx index d01f6f02b20756eab34cb3b86ed523662c72803f..4d2132c36c1538d9abb0477e0b726171bacd124b 100644 --- a/frontend/src/routes/sponsor/guest/guestInfo/index.tsx +++ b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx @@ -23,7 +23,8 @@ import { Guest } from 'interfaces' import SponsorInfoButtons from 'routes/components/sponsorInfoButtons' import { useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { isValidEmail, submitJsonOpts } from '../../../../utils' +import IdentityLine from 'components/identityLine' +import { isValidEmail, submitJsonOpts } from 'utils' type GuestInfoParams = { pid: string @@ -34,6 +35,7 @@ type GuestInfoProps = { updateEmail: (validEmail: string) => void resend: () => void reloadGuests: () => void + reloadGuest: () => void } type CancelConfirmationDialogProps = { @@ -83,6 +85,7 @@ export default function GuestInfo({ updateEmail, resend, reloadGuests, + reloadGuest, }: GuestInfoProps) { const { pid } = useParams<GuestInfoParams>() const [t] = useTranslation(['common']) @@ -164,6 +167,7 @@ export default function GuestInfo({ <TableRow> <TableCell align="left">{t('guestInfo.contactInfo')}</TableCell> <TableCell /> + <TableCell /> </TableRow> </TableHead> <TableBody> @@ -172,6 +176,7 @@ export default function GuestInfo({ <TableCell align="left"> {`${guest.first} ${guest.last}`} </TableCell> + <TableCell /> </TableRow> <TableRow> <TableCell align="left">{t('input.email')}</TableCell> @@ -224,11 +229,18 @@ export default function GuestInfo({ onClose={handleDialogClose} /> </TableCell> + <TableCell /> </TableRow> - <TableRow> - <TableCell align="left">{t('input.nationalIdNumber')}</TableCell> - <TableCell align="left">{guest.fnr}</TableCell> - </TableRow> + <IdentityLine + text={t('input.nationalIdNumber')} + identity={guest.fnr} + reloadGuest={reloadGuest} + /> + <IdentityLine + text={t('input.passportNumber')} + identity={guest.passport} + reloadGuest={reloadGuest} + /> <TableRow> <TableCell align="left">{t('input.mobilePhone')}</TableCell> <TableCell align="left">{guest.mobile}</TableCell> diff --git a/frontend/src/routes/sponsor/guest/index.tsx b/frontend/src/routes/sponsor/guest/index.tsx index 8046bae7ae41923a7b2517d68c258d563a258ff2..49a092dd5ea76030576489ef922838f921279813 100644 --- a/frontend/src/routes/sponsor/guest/index.tsx +++ b/frontend/src/routes/sponsor/guest/index.tsx @@ -67,6 +67,7 @@ function GuestRoutes({ reloadGuests }: GuestRoutesProps) { updateEmail={updateEmail} resend={resend} reloadGuests={reloadGuests} + reloadGuest={reloadGuestInfo} /> </Route> </> diff --git a/frontend/src/routes/sponsor/index.tsx b/frontend/src/routes/sponsor/index.tsx index 0ae812164733dbdfabd7d28e81ce72ae24425666..e429db43d1ce22f8696458662efc9e8d3bff00e6 100644 --- a/frontend/src/routes/sponsor/index.tsx +++ b/frontend/src/routes/sponsor/index.tsx @@ -1,8 +1,8 @@ import { Route } from 'react-router-dom' import FrontPage from 'routes/sponsor/frontpage' +import useGuests from 'hooks/useGuests' import GuestRoutes from './guest' -import useGuests from '../../hooks/useGuests' function Sponsor() { const { guests, reloadGuests } = useGuests() diff --git a/frontend/src/routes/sponsor/register/frontPage.tsx b/frontend/src/routes/sponsor/register/frontPage.tsx index d53ef156375a9e4bdff4b9ffaebac85544ce6548..21b38e57ec36fcda71249d411f5c1bcf10a20668 100644 --- a/frontend/src/routes/sponsor/register/frontPage.tsx +++ b/frontend/src/routes/sponsor/register/frontPage.tsx @@ -22,12 +22,12 @@ function FrontPage() { if (event.target.value) { console.log('searching') const response = await fetch( - `/api/ui/v1/person/search/${event.target.value}` + `/api/ui/v1/person/search/${event.target.value}?format=json` ) const repjson = await response.json() console.log(repjson) if (response.ok) { - setGuests(repjson.persons) + setGuests(repjson) } } } @@ -52,7 +52,11 @@ function FrontPage() { guests.map((guest) => { const guestTo = `/sponsor/guest/${guest.pid}` return ( - <MenuItem component={Link} to={guestTo}> + <MenuItem + key={`${guest.pid}-${guest.value}`} + component={Link} + to={guestTo} + > {guest.first} {guest.last} <br /> {guest.value} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 08dfed2f91f97bb866ac7efbabd88983711472d0..88b23193afdf34bca4ddb5f26a20dd3227d518f1 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,7 +1,7 @@ import validator from '@navikt/fnrvalidator' import { parseISO } from 'date-fns' import i18n from 'i18next' -import { FetchedRole, Role } from 'interfaces' +import { FetchedIdentity, FetchedRole, Identity, Role } from 'interfaces' import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js' const validEmailRegex = @@ -122,3 +122,17 @@ export function parseRole(role: FetchedRole): Role { max_days: role.max_days, } } + +export function parseIdentity( + identity: FetchedIdentity | null +): Identity | null { + if (identity == null) { + return null + } + return { + id: identity.id, + type: identity.type, + value: identity.value, + verified_at: identity.verified_at ? parseISO(identity.verified_at) : null, + } +} diff --git a/greg/api/serializers/identity.py b/greg/api/serializers/identity.py index 8cfd25538ddfee0fb033819807b73d76e3c596d2..9caf8270f2b517874a2f3f3056e288e2f346b64f 100644 --- a/greg/api/serializers/identity.py +++ b/greg/api/serializers/identity.py @@ -24,3 +24,9 @@ class IdentitySerializer(serializers.ModelSerializer): if self.is_duplicate(attrs["type"], attrs["value"]): raise ValidationError("Identity already exists") return attrs + + +class SpecialIdentitySerializer(serializers.ModelSerializer): + class Meta: + model = Identity + fields = ["id", "value", "type", "verified_at"] diff --git a/greg/api/serializers/organizational_unit.py b/greg/api/serializers/organizational_unit.py index 95258a1070bdb4324e0f5d1c288e03d8365794bf..1053f79bf934f59c4b6df11d1a2b4f8cd3ac3384 100644 --- a/greg/api/serializers/organizational_unit.py +++ b/greg/api/serializers/organizational_unit.py @@ -1,4 +1,5 @@ -from rest_framework.serializers import ModelSerializer +from rest_framework.serializers import ModelSerializer, CharField + from greg.api.serializers.ouidentifier import OuIdentifierSerializer from greg.models import OrganizationalUnit @@ -20,3 +21,12 @@ class OrganizationalUnitSerializer(ModelSerializer): "parent", "identifiers", ] + + +class SponsorOrgUnitsSerializer(ModelSerializer): + nb = CharField(source="name_nb") + en = CharField(source="name_en") + + class Meta: + model = OrganizationalUnit + fields = ["id", "nb", "en"] diff --git a/greg/api/serializers/person.py b/greg/api/serializers/person.py index f8fe4c9f45c4fe546c32bdb116b810e62c776cda..4327b3d94538e49537a05dca0efcc221df5177b0 100644 --- a/greg/api/serializers/person.py +++ b/greg/api/serializers/person.py @@ -1,9 +1,10 @@ from rest_framework import serializers +from rest_framework.fields import BooleanField, CharField, SerializerMethodField from greg.api.serializers.consent import ConsentSerializerBrief -from greg.api.serializers.identity import IdentitySerializer -from greg.api.serializers.role import RoleSerializer -from greg.models import Person +from greg.api.serializers.identity import IdentitySerializer, SpecialIdentitySerializer +from greg.api.serializers.role import RoleSerializer, SpecialRoleSerializer +from greg.models import Person, Identity class PersonSerializer(serializers.ModelSerializer): @@ -23,3 +24,59 @@ class PersonSerializer(serializers.ModelSerializer): "roles", "consents", ] + + +class SpecialPersonSerializer(serializers.ModelSerializer): + """ + Serializer for the person endpoint + + Can be used to change or add an email to the person + + """ + + pid = CharField(source="id", read_only=True) + first = CharField(source="first_name", read_only=True) + last = CharField(source="last_name", read_only=True) + email = SerializerMethodField(source="private_email") + mobile = SerializerMethodField(source="private_mobile", read_only=True) + fnr = SpecialIdentitySerializer(read_only=True) + passport = SpecialIdentitySerializer(read_only=True) + active = SerializerMethodField(source="active", read_only=True) + registered = BooleanField(source="is_registered", read_only=True) + verified = BooleanField(source="is_verified", read_only=True) + roles = SpecialRoleSerializer(many=True, read_only=True) + + def get_email(self, obj): + return obj.private_email and obj.private_email.value + + def get_mobile(self, obj): + return obj.private_mobile and obj.private_mobile.value + + def get_active(self, obj): + return obj.is_registered and obj.is_verified + + class Meta: + model = Person + fields = [ + "pid", + "first", + "last", + "mobile", + "fnr", + "email", + "passport", + "active", + "registered", + "verified", + "roles", + ] + + +class PersonSearchSerializer(serializers.ModelSerializer): + pid = CharField(source="person.id") + first = CharField(source="person.first_name") + last = CharField(source="person.last_name") + + class Meta: + model = Identity + fields = ["pid", "first", "last", "value", "type"] diff --git a/greg/api/serializers/role.py b/greg/api/serializers/role.py index 31b2a43f187644fd83675982c5738567a3eea0ea..5b9c3b20a12a78fc4c5bfba273560434d5f127f1 100644 --- a/greg/api/serializers/role.py +++ b/greg/api/serializers/role.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from rest_framework.fields import IntegerField +from rest_framework.fields import IntegerField, SerializerMethodField from greg.api.serializers.organizational_unit import OrganizationalUnitSerializer from greg.models import Role, RoleType @@ -33,3 +33,39 @@ class RoleWriteSerializer(RoleSerializer): """ orgunit = IntegerField(source="orgunit_id") # type: ignore + + +class SpecialRoleSerializer(serializers.ModelSerializer): + name_nb = SerializerMethodField(source="type") + name_en = SerializerMethodField(source="type") + ou_nb = SerializerMethodField(source="orgunit") + ou_en = SerializerMethodField(source="orgunit") + max_days = SerializerMethodField(source="type") + + def get_name_nb(self, obj): + return obj.type.name_nb + + def get_name_en(self, obj): + return obj.type.name_en + + def get_ou_nb(self, obj): + return obj.orgunit.name_nb + + def get_ou_en(self, obj): + return obj.orgunit.name_en + + def get_max_days(self, obj): + return obj.type.max_days + + class Meta: + model = Role + fields = [ + "id", + "name_nb", + "name_en", + "ou_nb", + "ou_en", + "start_date", + "end_date", + "max_days", + ] diff --git a/greg/migrations/0016_identity_allow_blank.py b/greg/migrations/0016_identity_allow_blank.py new file mode 100644 index 0000000000000000000000000000000000000000..fdbf3188e2057b32389762cd98ab06c7a2354de8 --- /dev/null +++ b/greg/migrations/0016_identity_allow_blank.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.9 on 2021-11-23 12:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("greg", "0015_add_feide_email"), + ] + + operations = [ + migrations.AlterField( + model_name="identity", + name="verified_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="identity", + name="verified_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="sponsor", + to="greg.sponsor", + ), + ), + ] diff --git a/greg/models.py b/greg/models.py index a80b7f17d8a83633f1ae90ac00a949caa47800c9..83b64287984912fe3d870e05b5178c15b08e3815 100644 --- a/greg/models.py +++ b/greg/models.py @@ -289,9 +289,13 @@ class Identity(BaseModel): value = models.CharField(max_length=256) verified = models.CharField(max_length=9, choices=Verified.choices, blank=True) verified_by = models.ForeignKey( - "Sponsor", on_delete=models.PROTECT, related_name="sponsor", null=True + "Sponsor", + on_delete=models.PROTECT, + related_name="sponsor", + null=True, + blank=True, ) - verified_at = models.DateTimeField(null=True) + verified_at = models.DateTimeField(null=True, blank=True) def __str__(self): return "{}(id={!r}, type={!r}, value={!r})".format( diff --git a/gregui/api/serializers/identity.py b/gregui/api/serializers/identity.py new file mode 100644 index 0000000000000000000000000000000000000000..df8d06b1a9b1bc059696953b1ef5869bfd22b06e --- /dev/null +++ b/gregui/api/serializers/identity.py @@ -0,0 +1,52 @@ +from django.utils import timezone +from rest_framework import serializers + +from greg.models import Identity +from gregui.models import GregUserProfile + + +class IdentitySerializer(serializers.ModelSerializer): + """Serializer for the Identity model with validation of various fields""" + + class Meta: + model = Identity + fields = "__all__" + read_only_fields = [ + "id", + "person", + "type", + "source", + "value", + "verified", + "verified_by", + ] + + def _get_sponsor(self): + """ + Fetch the sponsor doing the request + + Since the ViewSet using this Serializer uses the IsSponsor permission, we know + that the user is connect to a GregUserProfile with a sponsor object. + """ + user = None + request = self.context.get("request") + if request and hasattr(request, "user"): + user = request.user + return GregUserProfile.objects.get(user=user).sponsor + + def validate(self, attrs): + """ + Set values automatically when updating. + + - All updates are manual from the ui endpoints. + - The one verifying must be the logged in user which we know is a sponsor since + the user passed the IsSponsor permission in the view using this serializer. + - No point leaving setting the verified_at time to anyone other than the + server itself. + + Note: Get requests do not use this method, making it safe. + """ + attrs["verified"] = Identity.Verified.MANUAL + attrs["verified_by"] = self._get_sponsor() + attrs["verified_at"] = timezone.now() + return attrs diff --git a/gregui/api/urls.py b/gregui/api/urls.py index 6cb9764a4785481399ff927cc176716ea5ef527b..d7fa5f1febaa1b411ae31c6ed5e84fbe4dd587c6 100644 --- a/gregui/api/urls.py +++ b/gregui/api/urls.py @@ -1,5 +1,6 @@ from django.urls import re_path, path from rest_framework.routers import DefaultRouter +from gregui.api.views.identity import IdentityViewSet from gregui.api.views.invitation import ( CheckInvitationView, @@ -7,13 +8,18 @@ from gregui.api.views.invitation import ( InvitationView, InvitedGuestView, ) -from gregui.api.views.person import PersonSearchView, PersonView +from gregui.api.views.ou import OusViewSet +from gregui.api.views.person import GuestInfoViewSet, PersonSearchViewSet, PersonViewSet from gregui.api.views.role import RoleInfoViewSet from gregui.api.views.roletypes import RoleTypeViewSet from gregui.api.views.unit import UnitsViewSet router = DefaultRouter(trailing_slash=False) router.register(r"role", RoleInfoViewSet, basename="role") +router.register(r"identity", IdentityViewSet, basename="identity") +router.register(r"ous", OusViewSet, basename="ou") +router.register(r"person", PersonViewSet, basename="person") +router.register(r"guests/", GuestInfoViewSet, basename="guests") urlpatterns = router.urls urlpatterns += [ @@ -27,8 +33,9 @@ urlpatterns += [ name="invite-resend", ), path("invite/", InvitationView.as_view(), name="invitation"), - path("person/<int:id>", PersonView.as_view(), name="person-get"), - path( - "person/search/<searchstring>", PersonSearchView.as_view(), name="person-search" + re_path( + r"person/search/(?P<searchstring>\S+)", # search for sequence of any non-whitespace char + PersonSearchViewSet.as_view({"get": "list"}), + name="person-search", ), ] diff --git a/gregui/api/views/identity.py b/gregui/api/views/identity.py new file mode 100644 index 0000000000000000000000000000000000000000..18c88586611af8ad85e7a291b9bb115960d6a9ea --- /dev/null +++ b/gregui/api/views/identity.py @@ -0,0 +1,31 @@ +from rest_framework.authentication import SessionAuthentication, BasicAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import GenericViewSet +from rest_framework import mixins +from rest_framework.exceptions import MethodNotAllowed +from greg.models import Identity +from greg.permissions import IsSponsor +from gregui.api.serializers.identity import IdentitySerializer +from gregui.models import GregUserProfile + + +class IdentityViewSet( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + GenericViewSet, +): + """ + Fetch or update identity info for any guest as long as you are a sponsor. + + This is required for when a host(sponsor) needs to verify the identity of a guest + so that they are considered "active". + + Limited to GET and PATCH so that we can only update or get a single identity at a + time. + """ + + queryset = Identity.objects.all() + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated, IsSponsor] + serializer_class = IdentitySerializer + http_method_names = ["get", "patch"] diff --git a/gregui/api/views/ou.py b/gregui/api/views/ou.py new file mode 100644 index 0000000000000000000000000000000000000000..362f2211d1a9eb073657644f50a9d797d28e7f9d --- /dev/null +++ b/gregui/api/views/ou.py @@ -0,0 +1,20 @@ +from rest_framework import mixins +from rest_framework.authentication import SessionAuthentication, BasicAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import GenericViewSet +from greg.api.serializers.organizational_unit import SponsorOrgUnitsSerializer + +from greg.permissions import IsSponsor +from gregui.models import GregUserProfile + + +class OusViewSet(mixins.ListModelMixin, GenericViewSet): + """Fetch Ous related to the authenticated sponsor.""" + + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated, IsSponsor] + serializer_class = SponsorOrgUnitsSerializer + + def get_queryset(self): + sponsor = GregUserProfile.objects.get(user=self.request.user).sponsor + return sponsor.units.all() diff --git a/gregui/api/views/person.py b/gregui/api/views/person.py index 50ed292122d6e3b9fe71e715c8b9510475ebe926..fec45bb3b1467dccb7dd6dcae4b5e01705a9c2da 100644 --- a/gregui/api/views/person.py +++ b/gregui/api/views/person.py @@ -1,18 +1,18 @@ -from django.http.response import JsonResponse -from django.utils.timezone import now -from rest_framework import status +from rest_framework import mixins from rest_framework.authentication import SessionAuthentication, BasicAuthentication from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView +from rest_framework.viewsets import GenericViewSet +from greg.api.serializers.person import PersonSearchSerializer, SpecialPersonSerializer -from greg.models import Identity, Person, InvitationLink +from greg.models import Identity, Person from greg.permissions import IsSponsor +from gregui import validation from gregui.api.serializers.guest import create_identity_or_update -from gregui.validation import validate_email +from gregui.models import GregUserProfile -class PersonView(APIView): +class PersonViewSet(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, GenericViewSet): + """ Fetch person info for any guest as long as you are a sponsor @@ -24,72 +24,48 @@ class PersonView(APIView): authentication_classes = [SessionAuthentication, BasicAuthentication] permission_classes = [IsAuthenticated, IsSponsor] + queryset = Person.objects.all() + http_methods = ["get", "patch"] + serializer_class = SpecialPersonSerializer - def get(self, request, id): - person = Person.objects.get(id=id) - response = { - "pid": person.id, - "first": person.first_name, - "last": person.last_name, - "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, - "name_nb": role.type.name_nb, - "name_en": role.type.name_en, - "ou_nb": role.orgunit.name_nb, - "ou_en": role.orgunit.name_en, - "start_date": role.start_date, - "end_date": role.end_date, - "max_days": role.type.max_days, - } - for role in person.roles.all() - ], - } - return JsonResponse(response) - - def patch(self, request, id): - person = Person.objects.get(id=id) - # For now only the e-mail is allowed to be updated - email = request.data.get("email") - if not email: - return Response(status=status.HTTP_400_BAD_REQUEST) - validate_email(email) - # The following line will raise an exception if the e-mail is not valid + def perform_update(self, serializer): + """Update email when doing patch""" + email = self.request.data.get("email") + person = self.get_object() + validation.validate_email(email) create_identity_or_update(Identity.IdentityType.PRIVATE_EMAIL, email, person) - - return Response(status=status.HTTP_200_OK) + return super().perform_update(serializer) -class PersonSearchView(APIView): +class PersonSearchViewSet(mixins.ListModelMixin, GenericViewSet): """Search for persons using email or phone number""" authentication_classes = [SessionAuthentication, BasicAuthentication] permission_classes = [IsAuthenticated, IsSponsor] + serializer_class = PersonSearchSerializer - def get(self, requests, searchstring): - search = Identity.objects.filter( - value__icontains=searchstring, # icontains to include wrong case emails + def get_queryset(self): + search = self.kwargs["searchstring"] + return Identity.objects.filter( + value__icontains=search, # icontains to include wrong case emails type__in=[ Identity.IdentityType.PRIVATE_EMAIL, Identity.IdentityType.PRIVATE_MOBILE_NUMBER, ], )[:10] - response = { - "persons": [ - { - "pid": i.person.id, - "first": i.person.first_name, - "last": i.person.last_name, - "value": i.value, - "type": i.type, - } - for i in search - ] - } - return JsonResponse(response) + + +class GuestInfoViewSet(mixins.ListModelMixin, GenericViewSet): + """ + Fetch all the sponsor's guests. + + Lists all persons connected to the roles the logged in sponsor is connected to. + """ + + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated, IsSponsor] + serializer_class = SpecialPersonSerializer + + def get_queryset(self): + user = GregUserProfile.objects.get(user=self.request.user) + return Person.objects.filter(roles__sponsor=user.sponsor).distinct() diff --git a/gregui/tests/api/views/test_person.py b/gregui/tests/api/views/test_person.py index 4afb1f771111df9a3266ae98622b9b05d934a824..e4a5578c3c37600409cd38843b7f3856f9383e85 100644 --- a/gregui/tests/api/views/test_person.py +++ b/gregui/tests/api/views/test_person.py @@ -6,7 +6,7 @@ from rest_framework.reverse import reverse @pytest.mark.django_db def test_get_person_fail(client): """Anonymous user cannot get person info""" - url = reverse("gregui-v1:person-get", kwargs={"id": 1}) + url = reverse("gregui-v1:person-detail", kwargs={"pk": 1}) response = client.get(url) assert response.status_code == status.HTTP_403_FORBIDDEN @@ -15,7 +15,7 @@ def test_get_person_fail(client): def test_get_person(client, log_in, user_sponsor, invited_person): """Logged in sponsor can get person info""" person, _ = invited_person - url = reverse("gregui-v1:person-get", kwargs={"id": person.id}) + url = reverse("gregui-v1:person-detail", kwargs={"pk": person.id}) log_in(user_sponsor) response = client.get(url) assert response.status_code == status.HTTP_200_OK @@ -25,7 +25,7 @@ def test_get_person(client, log_in, user_sponsor, invited_person): def test_patch_person_no_data_fail(client, log_in, user_sponsor, invited_person): """No data in patch should fail""" person, _ = invited_person - url = reverse("gregui-v1:person-get", kwargs={"id": person.id}) + url = reverse("gregui-v1:person-detail", kwargs={"pk": person.id}) log_in(user_sponsor) response = client.patch(url) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -35,7 +35,7 @@ def test_patch_person_no_data_fail(client, log_in, user_sponsor, invited_person) def test_patch_person_new_email_ok(client, log_in, user_sponsor, invited_person): """Logged in sponsor can update email address of person""" person, _ = invited_person - url = reverse("gregui-v1:person-get", kwargs={"id": person.id}) + url = reverse("gregui-v1:person-detail", kwargs={"pk": person.id}) log_in(user_sponsor) assert person.private_email.value == "foo@example.org" response = client.patch(url, data={"email": "new@example.com"}) diff --git a/gregui/urls.py b/gregui/urls.py index c8a47cbfd83d1644e146a099de86790e6898a16b..4292a73e4146722848d2b9f7d476f037bd99e87a 100644 --- a/gregui/urls.py +++ b/gregui/urls.py @@ -6,7 +6,6 @@ from django.urls.resolvers import URLResolver from gregui.api import urls as api_urls from gregui.api.views.userinfo import UserInfoView -from gregui.views import OusView, GuestInfoView from . import views urlpatterns: List[URLResolver] = [ @@ -20,6 +19,4 @@ urlpatterns: List[URLResolver] = [ 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 - path("api/ui/v1/ous/", OusView.as_view()), - path("api/ui/v1/guests/", GuestInfoView.as_view()), ] diff --git a/gregui/validation.py b/gregui/validation.py index 18ad553d8687672412ef1cbb148bccf7705f5c53..ecdfca28a2465283a56a50a98747a03fd79a378f 100644 --- a/gregui/validation.py +++ b/gregui/validation.py @@ -1,4 +1,5 @@ import re +from typing import Optional import phonenumbers from rest_framework import serializers @@ -19,6 +20,6 @@ def validate_phone_number(value): raise serializers.ValidationError("Invalid phone number") -def validate_email(value): - if not re.fullmatch(_valid_email_regex, value): +def validate_email(value: Optional[str]): + if not value or not re.fullmatch(_valid_email_regex, value): raise serializers.ValidationError("Invalid e-mail") diff --git a/gregui/views.py b/gregui/views.py index 4721cccb27e0426fa2874796671bb62d1b55dbaa..a60d3b760e25993ea4005b78fad4d6850fc472f9 100644 --- a/gregui/views.py +++ b/gregui/views.py @@ -6,10 +6,7 @@ from rest_framework.authentication import SessionAuthentication, BasicAuthentica from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView -from greg.models import Person, Sponsor -from greg.permissions import IsSponsor from gregui import mailutils -from gregui.models import GregUserProfile def get_csrf(request): @@ -61,70 +58,3 @@ class WhoAmIView(APIView): # pylint: disable=W0622 def get(request, format=None): return JsonResponse({"username": request.user.username}) - - -class OusView(APIView): - """Fetch Ous related to the authenticated sponsor.""" - - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated, IsSponsor] - - @staticmethod - # pylint: disable=W0622 - def get(request, format=None): - profile = GregUserProfile.objects.get(user=request.user) - sponsor = Sponsor.objects.get(id=profile.sponsor.id) - return JsonResponse( - { - "ous": [ - {"id": i.id, "nb": i.name_nb, "en": i.name_en} - for i in sponsor.units.all() - ] - } - ) - - -class GuestInfoView(APIView): - """Fetch all the sponsors guests""" - - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated, IsSponsor] - - @staticmethod - # pylint: disable=W0622 - def get(request, format=None): - user = GregUserProfile.objects.get(user=request.user) - - return JsonResponse( - { - "persons": [ - { - "pid": person.id, - "first": person.first_name, - "last": person.last_name, - "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, - "name_nb": role.type.name_nb, - "name_en": role.type.name_en, - "ou_nb": role.orgunit.name_nb, - "ou_en": role.orgunit.name_en, - "start_date": role.start_date, - "end_date": role.end_date, - "max_days": role.type.max_days, - } - for role in person.roles.all() - ], - } - for person in Person.objects.filter( - roles__sponsor=user.sponsor - ).distinct() - ] - } - )