Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • andretol/greg
1 result
Show changes
Showing
with 366 additions and 135 deletions
......@@ -64,6 +64,7 @@ export type FetchedRole = {
export interface User {
auth: boolean
auth_type: string
fetched: boolean
person_id: string
sponsor_id: string
......
......@@ -10,6 +10,7 @@ function UserProvider(props: UserProviderProps) {
const { children } = props
const [user, setUser] = useState({
auth: false,
auth_type: '',
fetched: false,
first_name: '',
last_name: '',
......@@ -31,6 +32,7 @@ function UserProvider(props: UserProviderProps) {
if (response.ok) {
setUser({
auth: true,
auth_type: data.auth_type,
fetched: true,
first_name: data.first_name,
last_name: data.last_name,
......@@ -46,6 +48,7 @@ function UserProvider(props: UserProviderProps) {
} else {
setUser({
auth: false,
auth_type: '',
fetched: true,
first_name: '',
last_name: '',
......@@ -62,6 +65,7 @@ function UserProvider(props: UserProviderProps) {
} catch (error) {
setUser({
auth: false,
auth_type: '',
fetched: true,
first_name: '',
last_name: '',
......@@ -92,6 +96,7 @@ function UserProvider(props: UserProviderProps) {
const clearUserInfo = () => {
setUser({
auth: false,
auth_type: '',
fetched: false,
first_name: '',
last_name: '',
......
......@@ -48,6 +48,9 @@ const Header = () => {
const { user } = useUserContext()
const { t } = useTranslation('common')
const logoutLink =
user.auth_type === 'oidc' ? '/oidc/logout' : '/invite/logout'
return (
<StyledHeader>
<MainContainer>
......@@ -61,7 +64,7 @@ const Header = () => {
sx={{
color: 'white',
}}
href="/oidc/logout"
href={logoutLink}
>
{t('button.logout')}
<LogoutIcon
......
......@@ -2,11 +2,9 @@ import { useTranslation } from 'react-i18next'
import { styled } from '@mui/system'
import { Container } from '@mui/material'
import { HrefButton } from 'components/button'
const StyledWrapper = styled('div')({
paddingTop: '2rem',
})
import LoginBox from 'components/loginBox'
import { instNameUpperCaser } from 'utils'
import { appInst } from 'appConfig'
const StyledParagraph = styled('p')({
fontSize: '1rem',
......@@ -16,12 +14,33 @@ export default function LoginPage() {
const { t } = useTranslation(['frontpage'])
return (
<StyledWrapper>
<Container maxWidth="sm">
<h2>{t('header')}</h2>
<StyledParagraph>{t('description')}</StyledParagraph>
<HrefButton to="/oidc/authenticate/">{t('login')}</HrefButton>
<Container
maxWidth="md"
sx={{
display: 'flex',
flexDirection: 'column',
marginTop: '4rem',
marginBottom: '10rem',
}}
>
<Container>
<h1>{t('header')}</h1>
<StyledParagraph sx={{ marginBottom: '4rem' }}>
{t('description', { inst: instNameUpperCaser(appInst) })}
</StyledParagraph>
</Container>
<Container
sx={{
display: 'flex',
justifyContent: 'space-between',
flexDirection: { xs: 'column', md: 'row' },
alignItems: 'center',
}}
>
<LoginBox header={t('sponsor')} info={t('sponsorInfo')} />
<LoginBox header={t('guest')} info={t('guestInfo')} />
</Container>
</StyledWrapper>
</Container>
)
}
......@@ -397,7 +397,8 @@ const GuestRegisterStep = forwardRef(
control={control}
rules={{
// It is not required that the national ID number be filled in, the guest may not have
// one, so allow empty values for the validation to pass
// one, so allow empty values for the validation to pass. Note that both "fødselsnummer" and
// D-number are allowed as input
validate: (value) => isValidFnr(value, true),
}}
render={({ field }) => (
......
......@@ -13,6 +13,7 @@ import Register from 'routes/sponsor/register'
import FrontPage from 'routes/frontpage'
import Invite from 'routes/invite'
import InviteLink from 'routes/invitelink'
import LogoutInviteSession from 'routes/invitelink/logout'
import Footer from 'routes/components/footer'
import Header from 'routes/components/header'
import NotFound from 'routes/components/notFound'
......@@ -77,6 +78,7 @@ export default function App() {
<Register />
</ProtectedRoute>
<Route path="/invitelink/" component={InviteLink} />
<Route path="/invite/logout" component={LogoutInviteSession} />
<Route path="/invite/" component={Invite} />
<Route path="/guestregister" component={GuestRegister} />
<Route>
......
import { useEffect, useState } from 'react'
import { Redirect } from 'react-router-dom'
import { useCookies } from 'react-cookie'
import { Box, CircularProgress } from '@mui/material'
import { useUserContext } from 'contexts'
export default function LogoutInviteSession() {
// Fetch backend endpoint to preserve invite_id in backend session then redirect
const [, , removeCookie] = useCookies(['sessionid'])
const [loggedOut, setLoggedOut] = useState(false)
const { fetchUserInfo } = useUserContext()
useEffect(() => {
fetch('/api/ui/v1/invitecheck/', { method: 'DELETE' })
.then(() => removeCookie('sessionid'))
.then(() => fetchUserInfo())
.then(() => setLoggedOut(true))
}, [])
if (loggedOut) {
return <Redirect to="/" />
}
return (
<Box sx={{ margin: 'auto' }}>
<CircularProgress />
</Box>
)
}
......@@ -22,7 +22,7 @@ import {
import { Guest } from 'interfaces'
import SponsorInfoButtons from 'routes/components/sponsorInfoButtons'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { SubmitHandler, useForm, Controller } from 'react-hook-form'
import IdentityLine from 'components/identityLine'
import { isValidEmail, submitJsonOpts } from 'utils'
......@@ -93,13 +93,19 @@ export default function GuestInfo({
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false)
const [emailDirty, setEmailDirty] = useState(false)
const defaultValues = {
email: guest.email,
}
// Using useForm even though only the e-mail is allow to change at present, since useForm makes setup and validation easier
const {
register,
handleSubmit,
setValue,
control,
formState: { errors },
} = useForm<Email>({ mode: 'onChange' })
} = useForm({
mode: 'onChange',
defaultValues,
})
useEffect(() => {
setValue('email', guest.email)
......@@ -150,6 +156,7 @@ export default function GuestInfo({
}
const emailFieldChange = (event: any) => {
setValue('email', event.target.value)
if (event.target.value !== guest.email) {
setEmailDirty(true)
} else {
......@@ -188,14 +195,23 @@ export default function GuestInfo({
justifyContent: 'flex-start',
}}
>
<TextField
id="email"
error={!!errors.email}
helperText={errors.email && errors.email.message}
{...register(`email`, {
<Controller
name="email"
control={control}
rules={{
validate: isValidEmail,
})}
onChange={emailFieldChange}
}}
render={({ field: { value }, ...rest }) => (
<TextField
id="email"
label={t('input.email')}
error={!!errors.email}
helperText={errors.email && errors.email.message}
value={value}
{...rest}
onChange={emailFieldChange}
/>
)}
/>
{/* If the guest has not completed the registration process, he should have an invitation he has not responded to */}
......@@ -259,17 +275,7 @@ export default function GuestInfo({
</Button>
<h2>{t('guestInfo.roleInfoHead')}</h2>
<h3>
{t('guestInfo.roleInfoBody')}
<Button
variant="contained"
color="secondary"
component={Link}
to={`/sponsor/guest/${pid}/newrole`}
>
{t('sponsor.addRole')}
</Button>
</h3>
<h3>{t('guestInfo.roleInfoBody')}</h3>
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
......@@ -285,6 +291,18 @@ export default function GuestInfo({
{guest.roles.map((role) => (
<RoleLine key={role.id} pid={pid} role={role} />
))}
<TableRow>
<TableCell align="left">
<Button
variant="contained"
color="secondary"
component={Link}
to={`/sponsor/guest/${pid}/newrole`}
>
{t('sponsor.addRole')}
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
......
......@@ -2,8 +2,8 @@ export type RegisterFormData = {
first_name?: string
last_name?: string
role_type?: string
role_start?: Date
role_end?: Date
role_start: Date
role_end: Date
contact_person_unit?: string
comment?: string
ou_id?: number
......
......@@ -46,6 +46,7 @@ const StepPersonForm = forwardRef(
)
const roleTypes = useRoleTypes()
const { displayContactAtUnit, displayComment } = useContext(FeatureContext)
const today: Date = new Date()
const roleTypeSort = () => (a: RoleTypeData, b: RoleTypeData) => {
if (i18n.language === 'en') {
......@@ -204,6 +205,7 @@ const StepPersonForm = forwardRef(
name="role_start"
control={control}
rules={{ validate: validateStartDateBeforeEndDate }}
defaultValue={today}
render={({ field }) => (
<DatePicker
mask="____-__-__"
......@@ -225,6 +227,7 @@ const StepPersonForm = forwardRef(
<Controller
name="role_end"
control={control}
defaultValue={today}
render={({ field }) => (
<DatePicker
mask="____-__-__"
......
......@@ -14,6 +14,7 @@ import SubmitState from './submitState'
import SponsorGuestButtons from '../../components/sponsorGuestButtons'
import { submitJsonOpts } from '../../../utils'
import StepSubmitSuccess from './stepSubmitSuccess'
import ServerErrorReport from '../../../components/errorReport'
enum Steps {
RegisterStep = 0,
......@@ -21,6 +22,12 @@ enum Steps {
SuccessStep = 2,
}
interface SubmitErrorData {
statusCode: number
statusText: string
bodyText: string
}
/**
*
* This component controls the invite process where the sponsor
......@@ -33,8 +40,9 @@ export default function StepRegistration() {
first_name: undefined,
last_name: undefined,
role_type: undefined,
role_start: undefined,
role_end: undefined,
// Having role_start and role_end to be nullable caused problems when specifying a default value, so instead having them as non-null and use today as the default date here
role_start: new Date(),
role_end: new Date(),
comment: undefined,
ou_id: undefined,
email: undefined,
......@@ -44,6 +52,7 @@ export default function StepRegistration() {
const [activeStep, setActiveStep] = useState(0)
const personFormRef = useRef<PersonFormMethods>(null)
const [submitState, setSubmitState] = useState(SubmitState.NotSubmitted)
const [errorReport, setErrorReport] = useState<SubmitErrorData>()
const handleNext = () => {
if (activeStep === 0) {
......@@ -80,10 +89,18 @@ export default function StepRegistration() {
fetch('/api/ui/v1/invite/', submitJsonOpts('POST', payload))
.then((res) => {
if (!res.ok) {
setSubmitState(SubmitState.SubmitFailure)
return null
res.text().then((text) => {
setSubmitState(SubmitState.SubmitFailure)
setErrorReport({
statusCode: res.status,
statusText: res.statusText,
bodyText: text,
})
})
} else {
return res.text()
}
return res.text()
return null
})
.then((result) => {
if (result !== null) {
......@@ -185,12 +202,15 @@ export default function StepRegistration() {
{activeStep === Steps.SuccessStep && <StepSubmitSuccess />}
{/* TODO For now just showing a heading to give the user some feedback */}
{submitState === SubmitState.SubmitFailure && (
<Box>
<h2>Submit failure</h2>
</Box>
)}
{submitState === SubmitState.SubmitFailure &&
errorReport !== undefined && (
<ServerErrorReport
errorHeading={t('error.invitationCreationFailedHeader')}
statusCode={errorReport?.statusCode}
statusText={errorReport?.statusText}
errorBodyText={errorReport?.bodyText}
/>
)}
</Page>
)
}
......@@ -154,3 +154,20 @@ export function parseIdentity(
verified_at: identity.verified_at ? parseISO(identity.verified_at) : null,
}
}
/**
* Method to ensure the correct "styled" case of a institution acronym.
*
* @param instName An institution acronym in any case
* @returns The correct "styled" case acronym, or the input string if no match.
*/
export function instNameUpperCaser(instName: String): String {
switch (instName.toLocaleLowerCase()) {
case 'uio':
return 'UiO'
case 'uib':
return 'UiB'
default:
return instName
}
}
......@@ -10,14 +10,6 @@ class IdentitySerializer(serializers.ModelSerializer):
fields = "__all__"
def is_duplicate(self, identity_type: str, value: str) -> bool:
# Guests may be verified using another unrecognised identification method,
# which the sponsor is required to elaborate in the value column.
# In this case we cannot assume the union of the identity type and
# the value to be unique across all records.
if identity_type == Identity.IdentityType.OTHER:
return False
# If the type is a specific ID type, then duplicates are not expected
return Identity.objects.filter(type=identity_type).filter(value=value).exists()
def validate(self, attrs):
......
......@@ -22,6 +22,7 @@ class RoleSerializer(serializers.ModelSerializer):
"created",
"updated",
"type",
"available_in_search",
]
......
......@@ -12,12 +12,13 @@ orgreg/v3/ as the url argument (note the trailing slash).
"""
import datetime
import logging
from typing import Union, Mapping, Dict
from typing import Union, Mapping, Dict, Optional
import orgreg_client
from django.conf import settings
from django.core.management.base import BaseCommand
from orgreg_client import OrgUnit
from orgreg_client.models import ExternalKey
from greg.models import OrganizationalUnit, OuIdentifier
......@@ -34,68 +35,110 @@ class Command(BaseCommand):
):
"""Upsert any configured extra IDs from orgreg."""
for extra_id in settings.ORGREG_EXTRA_IDS:
matching_ids = [
x
for x in ou.external_keys
if x.source_system == extra_id["source"] and x.type == extra_id["type"]
]
if not matching_ids:
logger.warning(
"No %s id from %s found in OrgReg for ou %s",
extra_id["type"],
identity_in_orgreg = self._get_external_key_from_ou(extra_id, ou)
if identity_in_orgreg is not None:
self._upsert_identifier(
extra_id["source"],
extra_id["type"],
identity_in_orgreg.value,
ou.ou_id,
)
continue
if len(matching_ids) > 1:
# External_ids
logger.warning(
"Found multiple ids matching type: %s source: %s in OrgReg. Using the first one: %s",
extra_id["type"],
extra_id["source"],
matching_ids[0].value,
)
# Acronyms can also be used as identifiers
for acronym in settings.ORGREG_ACRONYMS:
self.handle_acronym_identifier(acronym, ou)
def handle_acronym_identifier(self, acronym, ou):
if acronym == "nob" and ou.acronym.nob is not None:
self._upsert_identifier(
settings.ORGREG_SOURCE,
"acronym_" + acronym,
ou.acronym.nob,
ou.ou_id,
)
if acronym == "eng" and ou.acronym.eng is not None:
self._upsert_identifier(
settings.ORGREG_SOURCE,
"acronym_" + acronym,
ou.acronym.eng,
ou.ou_id,
)
if acronym == "nno" and ou.acronym.nno is not None:
self._upsert_identifier(
settings.ORGREG_SOURCE,
"acronym_" + acronym,
ou.acronym.nno,
ou.ou_id,
)
@staticmethod
def _get_external_key_from_ou(
extra_id: Dict[str, str], ou: OrgUnit
) -> Optional[ExternalKey]:
matching_ids = [
x
for x in ou.external_keys
if x.source_system == extra_id["source"] and x.type == extra_id["type"]
]
if not matching_ids:
logger.warning(
"No %s id from %s found in OrgReg for ou %s",
extra_id["type"],
extra_id["source"],
ou.ou_id,
)
return None
if len(matching_ids) > 1:
# External_ids
logger.warning(
"Found multiple ids matching type: %s source: %s in OrgReg. Using the first one: %s",
extra_id["type"],
extra_id["source"],
matching_ids[0].value,
)
identity_in_orgreg = matching_ids[0]
return matching_ids[0]
# Check if the id exists
identify_in_db = (
self.processed[ou.ou_id]
.identifiers.filter(
source=extra_id["source"],
name=extra_id["type"],
)
.first()
def _upsert_identifier(
self, source: str, identity_type: str, identity_in_orgreg_value: str, ou_id: int
):
# Check if the id exists
identify_in_db = (
self.processed[ou_id]
.identifiers.filter(
source=source,
name=identity_type,
)
.first()
)
if identify_in_db:
if identify_in_db.value != identity_in_orgreg.value:
logger.info(
"Updating id: source: %s, type: %s, old_id: %s, new_id %s",
extra_id["source"],
extra_id["type"],
identify_in_db.value,
identity_in_orgreg.value,
)
identify_in_db.value = identity_in_orgreg.value
identify_in_db.save()
else:
OuIdentifier.objects.create(
name=extra_id["type"],
source=extra_id["source"],
value=identity_in_orgreg["value"],
orgunit=self.processed[ou.ou_id],
)
if identify_in_db:
if identify_in_db.value != identity_in_orgreg_value:
logger.info(
"Added new id to ou: %s, type: %s, source: %s value: %s",
ou.ou_id,
extra_id["type"],
extra_id["source"],
identity_in_orgreg["value"],
"Updating id: source: %s, type: %s, old_id: %s, new_id %s",
source,
identity_type,
identify_in_db.value,
identity_in_orgreg_value,
)
identify_in_db.value = identity_in_orgreg_value
identify_in_db.save()
else:
OuIdentifier.objects.create(
name=identity_type,
source=source,
value=identity_in_orgreg_value,
orgunit=self.processed[ou_id],
)
logger.info(
"Added new id to ou: %s, type: %s, source: %s value: %s",
ou_id,
identity_type,
source,
identity_in_orgreg_value,
)
def _get_or_create_and_set_values(
self, ou: OrgUnit, values: Mapping[str, Union[str, int, bool]]
......
......@@ -2,7 +2,7 @@
Adds a few more specific models for testing purposes. Alternative to the similiar
script for adding random data to various fields.
WARNING: This script removes all entries in most tables. Do not execute it unless you
WARNING: This command removes all entries in most tables. Do not execute it unless you
are absolutely certain you know what you are doing.
There are 4 guests:
......@@ -22,10 +22,11 @@ one of them has denied the other one.
"""
import datetime
import logging
from django.core.management.base import CommandError
from django.db import connection
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils import timezone
from greg.models import (
......@@ -55,8 +56,6 @@ OU_EUROPE_NAME_EN = "Europe"
CONSENT_IDENT_MANDATORY = "mandatory"
CONSENT_IDENT_OPTIONAL = "optional"
logger = logging.getLogger(__name__)
class DatabasePopulation:
"""
......@@ -66,7 +65,7 @@ class DatabasePopulation:
"""
def truncate_tables(self):
logger.info("truncating tables...")
print("truncating tables...")
with connection.cursor() as cursor:
for table in (
"greg_consent",
......@@ -85,9 +84,9 @@ class DatabasePopulation:
"greg_person",
"greg_sponsor",
):
logging.info("purging table %s", table)
print("purging table", table)
cursor.execute(f"DELETE FROM {table}")
logger.info("...tables purged")
print("...tables purged")
def _add_consent_types_and_choices(self):
mandatory = ConsentType.objects.create(
......@@ -391,7 +390,7 @@ class DatabasePopulation:
)
def populate_database(self):
logger.info("populating db...")
print("populating db...")
# Add the types, sponsors and ous
self._add_consent_types_and_choices()
self._add_ous_with_identifiers()
......@@ -402,10 +401,32 @@ class DatabasePopulation:
self._add_waiting_person()
self._add_invited_person()
self._add_expired_person()
logger.info("...done populating db")
print("...done populating db")
class Command(BaseCommand):
help = __doc__
def add_arguments(self, parser):
parser.add_argument(
"--destructive",
type=str,
required=True,
help="Verify database name. THIS COMMAND IS DESTRUCTIVE.",
)
def handle(self, *args, **options):
"""
Handle test data population
"""
db_name = str(settings.DATABASES["default"]["NAME"])
if options.get("destructive") != db_name:
raise CommandError(
"Must pass {!r} to --destructive, as its tables will be truncated".format(
db_name
)
)
if __name__ == "__main__":
database_population = DatabasePopulation()
database_population.truncate_tables()
database_population.populate_database()
database_population = DatabasePopulation()
database_population.truncate_tables()
database_population.populate_database()
# Generated by Django 3.2.9 on 2021-12-08 07:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("greg", "0017_consent_choices"),
]
operations = [
migrations.AlterField(
model_name="identity",
name="type",
field=models.CharField(
choices=[
("feide_id", "Feide Id"),
("feide_email", "Feide Email"),
("passport_number", "Passport Number"),
("norwegian_national_id_number", "Norwegian National Id Number"),
("private_email", "Private Email"),
("private_mobile", "Private Mobile Number"),
],
max_length=64,
),
),
]
......@@ -139,12 +139,12 @@ class Person(BaseModel):
Due to the diversity of guests at a university institution,
there are many ways for guests to identify themselves.
These include Feide ID, passport number, driver's license,
national ID card, or another manual (human) verification.
These include Feide ID, passport number, driver's license
and national ID card.
Some of these methods are implicitly trusted (Feide ID) because
the guest is likely a visitor from another academic institution
who has already been pre-verified. Others are manul, such
who has already been pre-verified. Others are manual, such
as the sponsor vouching for having checked the guest's
personal details against his or her passport.
......@@ -270,12 +270,10 @@ class Identity(BaseModel):
FEIDE_ID = "feide_id"
FEIDE_EMAIL = "feide_email"
PASSPORT_NUMBER = "passport_number"
# Norwegian national ID - "fødselsnummer"
# Norwegian national ID - "fødselsnummer" or D-number
NORWEGIAN_NATIONAL_ID_NUMBER = "norwegian_national_id_number"
PRIVATE_EMAIL = "private_email"
PRIVATE_MOBILE_NUMBER = "private_mobile"
# Sponsor writes what is used in the value column
OTHER = "other"
class Verified(models.TextChoices):
AUTOMATIC = "automatic"
......
......@@ -243,22 +243,24 @@ def test_identity_add_duplicate_fails(client, person_foo, person_bar):
@pytest.mark.django_db
def test_identity_add_valid_duplicate(client, person_foo, person_bar):
def test_add_invalid_type(client, person):
data = {
"type": Identity.IdentityType.OTHER,
"type": "OTHER",
"source": "Test source",
"value": "12345",
}
client.post(
reverse("v1:person_identity-list", kwargs={"person_id": person_bar.id}),
data=data,
)
client.post(
reverse("v1:person_identity-list", kwargs={"person_id": person_foo.id}),
assert len(person.identities.all()) == 0
response = client.post(
reverse("v1:person_identity-list", kwargs={"person_id": person.id}),
data=data,
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
person.refresh_from_db()
assert len(person.identities.all()) == 0
@pytest.mark.django_db
def test_identity_delete(client, person):
......
......@@ -34,6 +34,7 @@ def orgreg_response():
valid_from=datetime.date(year=2020, month=2, day=4),
external_keys=[],
name={"nob": "foo"},
acronym={"nob": "foo_acronym"},
),
OrgUnit(
ou_id=3,
......@@ -42,6 +43,7 @@ def orgreg_response():
parent=2,
name={"nob": "bar"},
external_keys=[],
acronym={"nob": "foo_acronym3"},
),
OrgUnit(
ou_id=2,
......@@ -49,6 +51,7 @@ def orgreg_response():
parent=1,
name={"eng": "baz"},
external_keys=[],
acronym={"nob": "foo_acronym2"},
),
]
)
......@@ -92,3 +95,26 @@ def test_run_twice(requests_mock, orgreg_response):
call_command("import_from_orgreg")
assert OrganizationalUnit.objects.all().count() == 3
@pytest.mark.django_db
def test_import_acronym(requests_mock, orgreg_response):
requests_mock.get("https://example.com/fake/ou/", text=orgreg_response.json())
settings.ORGREG_ACRONYMS.append("nob")
call_command("import_from_orgreg")
assert OrganizationalUnit.objects.all().count() == 3
assert (
OuIdentifier.objects.get(orgunit__id=1, name="acronym_nob").value
== "foo_acronym"
)
assert (
OuIdentifier.objects.get(orgunit__id=2, name="acronym_nob").value
== "foo_acronym2"
)
assert (
OuIdentifier.objects.get(orgunit__id=3, name="acronym_nob").value
== "foo_acronym3"
)