From 476b96c73248876ab43be036ac23319831cc5436 Mon Sep 17 00:00:00 2001 From: mohammad Date: Fri, 16 Sep 2022 14:05:57 +0200 Subject: [PATCH 01/11] Merge user nomad into the author edit quantity --- .../editQuantity/AuthorEditQuantity.js | 121 +++++++++++++++--- 1 file changed, 105 insertions(+), 16 deletions(-) diff --git a/gui/src/components/editQuantity/AuthorEditQuantity.js b/gui/src/components/editQuantity/AuthorEditQuantity.js index c2541bc72..9d5429cd1 100644 --- a/gui/src/components/editQuantity/AuthorEditQuantity.js +++ b/gui/src/components/editQuantity/AuthorEditQuantity.js @@ -15,63 +15,148 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, {useCallback, useEffect, useState} from 'react' +import React, {useCallback, useEffect, useMemo, useState} from 'react' import PropTypes from 'prop-types' import {getFieldProps, TextFieldWithHelp} from './StringEditQuantity' -import {makeStyles, Typography} from '@material-ui/core' +import {CircularProgress, InputAdornment, makeStyles, Typography} from '@material-ui/core' +import AutoComplete from '@material-ui/lab/Autocomplete' +import {debounce} from 'lodash' +import {fetchUsers} from '../uploads/EditMembersDialog' +import {useApi} from '../api' +import {useErrors} from '../errors' const useStyles = makeStyles(theme => ({ label: { marginLeft: theme.spacing(1) }, fields: { + width: '100%', marginTop: theme.spacing(1) } })) export const AuthorEditQuantity = React.memo((props) => { const {quantityDef, onChange, ...otherProps} = props + const {helpDescription, ...otherFieldProps} = otherProps const classes = useStyles() + const {api} = useApi() + const {raiseError} = useErrors() const [author, setAuthor] = useState(undefined) const [email, setEmail] = useState(otherProps?.value?.email) - const [emailError, setEmailError] = useState(false) + const [query, setQuery] = useState('') + const [suggestions, setSuggestions] = useState([]) + const [searching, setSearching] = useState(false) useEffect(() => { setAuthor(otherProps?.value) }, [otherProps?.value]) + const searchUsers = useCallback((value) => { + const newQuery = value.toLowerCase() + if (!(newQuery.startsWith(query) && suggestions.length === 0) || query === '') { + fetchUsers(api, query, newQuery) + .then(setSuggestions) + .catch(err => { + raiseError(err) + setSuggestions([]) + }) + } + setQuery(newQuery) + setSearching(false) + }, [api, raiseError, query, suggestions]) + const handleChange = useCallback((key, value) => { if (onChange) { - const newValue = {...author} - if (value) { - newValue[key] = value + let newValue = {...author} + if (key === 'user') { + setSuggestions(value ? [value] : []) + if (value) { + newValue['user_id'] = value?.user_id + newValue['first_name'] = value?.first_name + newValue['last_name'] = value?.last_name + newValue['affiliation'] = value?.affiliation + newValue['affiliation_address'] = value?.affiliation_address + delete newValue['email'] + setEmail(undefined) + setAuthor(newValue) + } else { + newValue = undefined + } } else { - delete newValue[key] + if (value) { + newValue[key] = value + } else { + delete newValue[key] + } } onChange(newValue && Object.keys(newValue).length !== 0 ? newValue : undefined) } }, [author, onChange]) + const isValidEmail = useCallback((value) => value ? value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) : true, []) + const handleEmailChange = useCallback((value) => { setEmail(value) - if (value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { + if (isValidEmail(value)) { handleChange('email', value) - setEmailError(false) - } else { - setEmailError(true) } - }, [handleChange]) + }, [handleChange, isValidEmail]) + + const debouncedSearchUsers = useMemo(() => debounce(searchUsers, 700), [searchUsers]) + + const handleUserInputChange = useCallback((event, value) => { + if (!(event && event.type && event.type === 'change')) { + return + } + setSearching(true) + debouncedSearchUsers(value) + }, [debouncedSearchUsers]) + + const user = useMemo(() => { + let user + if (otherFieldProps.value?.user_id) { + user = suggestions.find(option => option.user_id === otherFieldProps.value.user_id) + } + return user || null + }, [otherFieldProps.value, suggestions]) return {getFieldProps(quantityDef).label} + option ? (option.affiliation ? `${option.name} (${option.affiliation})` : option.name) : ''} + getOptionSelected={(option, value) => suggestions && value && value.user_id && option.user_id === value.user_id} + onInputChange={handleUserInputChange} + onChange={(event, value) => handleChange('user', value)} + value={user} + renderInput={params => ( + + + ) + } : params.InputProps)} + variant='filled' + size='small' + placeholder="NOMAD member's name" + margin='normal' + fullWidth + {...getFieldProps(quantityDef)} + label={'Nomad User'} + /> + )} + data-testid='user-edit-quantity' + /> handleChange('first_name', event.target.value)} fullWidth @@ -82,6 +167,7 @@ export const AuthorEditQuantity = React.memo((props) => { variant='filled' size='small' placeholder='Last name' + inputProps={{readOnly: !!otherProps?.value?.user_id}} value={otherProps?.value?.last_name} onChange={event => handleChange('last_name', event.target.value)} fullWidth @@ -92,6 +178,7 @@ export const AuthorEditQuantity = React.memo((props) => { variant='filled' size='small' placeholder='Affiliation' + inputProps={{readOnly: !!otherProps?.value?.user_id}} value={otherProps?.value?.affiliation} onChange={event => handleChange('affiliation', event.target.value)} fullWidth @@ -102,10 +189,11 @@ export const AuthorEditQuantity = React.memo((props) => { variant='filled' size='small' placeholder="Email address" + inputProps={{readOnly: !!otherProps?.value?.user_id}} value={email} onChange={event => handleEmailChange(event.target.value)} - error={emailError} - helperText={emailError && 'The email is not valid!'} + error={!isValidEmail(email)} + helperText={!isValidEmail(email) && 'The email is not valid!'} fullWidth /> { variant='filled' size='small' placeholder='Affiliation address' + inputProps={{readOnly: !!otherProps?.value?.user_id}} value={otherProps?.value?.affiliation_address} onChange={event => handleChange('affiliation_address', event.target.value)} fullWidth @@ -122,7 +211,7 @@ export const AuthorEditQuantity = React.memo((props) => { }) AuthorEditQuantity.propTypes = { quantityDef: PropTypes.object.isRequired, - value: PropTypes.string, + value: PropTypes.object, onChange: PropTypes.func } -- GitLab From 0986139af4a4ba261e3cafb707b32dbcc37967d5 Mon Sep 17 00:00:00 2001 From: mohammad Date: Fri, 16 Sep 2022 14:05:57 +0200 Subject: [PATCH 02/11] Change the labels --- gui/src/components/editQuantity/AuthorEditQuantity.js | 4 ++-- gui/src/components/editQuantity/UserEditQuantity.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gui/src/components/editQuantity/AuthorEditQuantity.js b/gui/src/components/editQuantity/AuthorEditQuantity.js index 9d5429cd1..446ae0d4c 100644 --- a/gui/src/components/editQuantity/AuthorEditQuantity.js +++ b/gui/src/components/editQuantity/AuthorEditQuantity.js @@ -142,11 +142,11 @@ export const AuthorEditQuantity = React.memo((props) => { } : params.InputProps)} variant='filled' size='small' - placeholder="NOMAD member's name" + placeholder="Search member's name" margin='normal' fullWidth {...getFieldProps(quantityDef)} - label={'Nomad User'} + label={'User'} /> )} data-testid='user-edit-quantity' diff --git a/gui/src/components/editQuantity/UserEditQuantity.js b/gui/src/components/editQuantity/UserEditQuantity.js index aaf51e0f4..635fae4f9 100644 --- a/gui/src/components/editQuantity/UserEditQuantity.js +++ b/gui/src/components/editQuantity/UserEditQuantity.js @@ -109,7 +109,7 @@ export const UserEditQuantity = React.memo((props) => { } : params.InputProps)} variant='filled' size='small' - placeholder="NOMAD member's name" + placeholder="Search member's name" margin='normal' fullWidth {...getFieldProps(quantityDef)} -- GitLab From 5df5be71a24817203f062ffb44a928404aa0a135 Mon Sep 17 00:00:00 2001 From: mohammad Date: Wed, 21 Sep 2022 11:15:09 +0200 Subject: [PATCH 03/11] Remove UserEditQuantity --- gui/src/components/archive/SectionEditor.js | 2 - .../editQuantity/EditQuantityExamples.js | 13 -- .../editQuantity/EditQuantityExamples.spec.js | 2 +- .../editQuantity/UserEditQuantity.js | 129 ------------------ 4 files changed, 1 insertion(+), 145 deletions(-) delete mode 100644 gui/src/components/editQuantity/UserEditQuantity.js diff --git a/gui/src/components/archive/SectionEditor.js b/gui/src/components/archive/SectionEditor.js index ace97aa6c..b22be545e 100644 --- a/gui/src/components/archive/SectionEditor.js +++ b/gui/src/components/archive/SectionEditor.js @@ -13,7 +13,6 @@ import {BoolEditQuantity} from '../editQuantity/BoolEditQuantity' import FileEditQuantity from '../editQuantity/FileEditQuantity' import RichTextEditQuantity from '../editQuantity/RichTextEditQuantity' import ReferenceEditQuantity from '../editQuantity/ReferenceEditQuantity' -import UserEditQuantity from '../editQuantity/UserEditQuantity' import AuthorEditQuantity from '../editQuantity/AuthorEditQuantity' import { QuantityMDef } from './metainfo' import { RadioEnumEditQuantity } from '../editQuantity/RadioEnumEditQuantity' @@ -31,7 +30,6 @@ const editQuantityComponents = { DateTimeEditQuantity: DateTimeEditQuantity, RichTextEditQuantity: RichTextEditQuantity, ReferenceEditQuantity: ReferenceEditQuantity, - UserEditQuantity: UserEditQuantity, AuthorEditQuantity: AuthorEditQuantity } diff --git a/gui/src/components/editQuantity/EditQuantityExamples.js b/gui/src/components/editQuantity/EditQuantityExamples.js index 5bf3bcf20..519bc6c87 100644 --- a/gui/src/components/editQuantity/EditQuantityExamples.js +++ b/gui/src/components/editQuantity/EditQuantityExamples.js @@ -30,7 +30,6 @@ import RichTextEditQuantity from './RichTextEditQuantity' import ListEditQuantity from './ListEditQuantity' import { Code } from '../buttons/SourceDialogButton' import { stripIndent } from '../../utils' -import UserEditQuantity from './UserEditQuantity' import AuthorEditQuantity from './AuthorEditQuantity' const enumValues = [ @@ -411,18 +410,6 @@ export function EditQuantityExamples() { /> - - - - - { await waitFor(() => expect(redirectButton()).toBeInTheDocument()) }) -test('Test UserEditQuantity', async () => { +test('Test User in AuthorEditQuantity', async () => { await startAPI('tests.states.uploads.empty', 'tests/data/editquantity/user') render() diff --git a/gui/src/components/editQuantity/UserEditQuantity.js b/gui/src/components/editQuantity/UserEditQuantity.js deleted file mode 100644 index 635fae4f9..000000000 --- a/gui/src/components/editQuantity/UserEditQuantity.js +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright The NOMAD Authors. - * - * This file is part of NOMAD. See https://nomad-lab.eu for further info. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, {useCallback, useEffect, useMemo, useState} from 'react' -import PropTypes from 'prop-types' -import {useApi} from "../api" -import {useErrors} from "../errors" -import AutoComplete from "@material-ui/lab/Autocomplete" -import {debounce} from "lodash" -import {getFieldProps, TextFieldWithHelp} from './StringEditQuantity' -import {fetchUsers} from '../uploads/EditMembersDialog' -import {CircularProgress, InputAdornment} from '@material-ui/core' - -export const UserEditQuantity = React.memo((props) => { - const {quantityDef, onChange, ...otherProps} = props - const {helpDescription, ...otherFieldProps} = otherProps - const {api} = useApi() - const {raiseError} = useErrors() - const [query, setQuery] = useState('') - const [user, setUser] = useState(undefined) - const [suggestions, setSuggestions] = useState([]) - const [searching, setSearching] = useState(false) - - const searchUsers = useCallback((value) => { - const newQuery = value.toLowerCase() - if (!(newQuery.startsWith(query) && suggestions.length === 0) || query === '') { - fetchUsers(api, query, newQuery) - .then(setSuggestions) - .catch(err => { - raiseError(err) - setSuggestions([]) - }) - } - setQuery(newQuery) - setSearching(false) - }, [api, raiseError, query, suggestions]) - - const getUser = useCallback((user_id) => { - return new Promise((resolve, reject) => { - if (!user_id) { - resolve(undefined) - } - api.get(`users?user_id=${user_id}`) - .then(response => { - const user = response['data']?.[0] - user ? resolve(user) : reject(new Error('Unable to find the member')) - }) - .catch(error => { - reject(new Error('Unable to find the member: ' + error)) - }) - }) - }, [api]) - - useEffect(() => { - getUser(otherProps.value).then(user => { - setSuggestions(user ? [user] : []) - setUser(user) - }) - }, [getUser, otherProps.value]) - - const debouncedSearchUsers = useMemo(() => debounce(searchUsers, 700), [searchUsers]) - - const handleInputChange = useCallback((event, value) => { - if (!(event && event.type && event.type === 'change')) { - return - } - setSearching(true) - debouncedSearchUsers(value) - }, [debouncedSearchUsers]) - - const handleChange = useCallback((event, value) => { - if (onChange) { - setSuggestions(value ? [value] : []) - setUser(value) - onChange(value?.user_id === '' ? undefined : value?.user_id) - } - }, [onChange]) - - return - option ? (option.affiliation ? `${option.name} (${option.affiliation})` : option.name) : ''} - getOptionSelected={(option, value) => suggestions && value && option.user_id === value.user_id} - onInputChange={handleInputChange} - onChange={handleChange} - value={suggestions.includes(user) ? user : null} - renderInput={params => ( - - - ) - } : params.InputProps)} - variant='filled' - size='small' - placeholder="Search member's name" - margin='normal' - fullWidth - {...getFieldProps(quantityDef)} - {...otherFieldProps} - /> - )} - data-testid='user-edit-quantity' - /> - -}) -UserEditQuantity.propTypes = { - quantityDef: PropTypes.object.isRequired, - value: PropTypes.string, - onChange: PropTypes.func -} - -export default UserEditQuantity -- GitLab From ced2354346613df9c600136681ad908bfc4fc635 Mon Sep 17 00:00:00 2001 From: mohammad Date: Wed, 21 Sep 2022 12:14:19 +0200 Subject: [PATCH 04/11] extend the User tests to UserAuthor tests --- .../editQuantity/EditQuantityExamples.spec.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/gui/src/components/editQuantity/EditQuantityExamples.spec.js b/gui/src/components/editQuantity/EditQuantityExamples.spec.js index b4707d930..77918c7ba 100644 --- a/gui/src/components/editQuantity/EditQuantityExamples.spec.js +++ b/gui/src/components/editQuantity/EditQuantityExamples.spec.js @@ -147,5 +147,25 @@ test('Test User in AuthorEditQuantity', async () => { await waitForGUI() await waitFor(() => expect(userFieldInput.value).toEqual('Markus Scheidgen (FHI)')) + const firstNameField = screen.getByText('First name').parentElement + const firstNameFieldInput = within(firstNameField).getByRole('textbox') + await waitFor(() => expect(firstNameFieldInput.value).toEqual('Markus')) + + const lastNameField = screen.getByText('Last name').parentElement + const lastNameFieldInput = within(lastNameField).getByRole('textbox') + await waitFor(() => expect(lastNameFieldInput.value).toEqual('Scheidgen')) + + const affiliationField = screen.getByText('Affiliation').parentElement + const affiliationFieldInput = within(affiliationField).getByRole('textbox') + await waitFor(() => expect(affiliationFieldInput.value).toEqual('FHI')) + + const emailField = screen.getByText('Email').parentElement + const emailFieldInput = within(emailField).getByRole('textbox') + await waitFor(() => expect(emailFieldInput.value).toEqual('')) + + const addressField = screen.getByText('Address').parentElement + const addressFieldInput = within(addressField).getByRole('textbox') + await waitFor(() => expect(addressFieldInput.value).toEqual('Berlin')) + closeAPI() }) -- GitLab From 75d855c0c0ce5a2a1a22281d194224bf3a3fc1ea Mon Sep 17 00:00:00 2001 From: mohammad Date: Fri, 23 Sep 2022 10:28:49 +0200 Subject: [PATCH 05/11] change the component based on the type_kind --- .../editQuantity/AuthorEditQuantity.js | 126 +++++++++--------- .../editQuantity/EditQuantityExamples.js | 17 +++ .../editQuantity/EditQuantityExamples.spec.js | 2 +- 3 files changed, 84 insertions(+), 61 deletions(-) diff --git a/gui/src/components/editQuantity/AuthorEditQuantity.js b/gui/src/components/editQuantity/AuthorEditQuantity.js index 446ae0d4c..d387d8124 100644 --- a/gui/src/components/editQuantity/AuthorEditQuantity.js +++ b/gui/src/components/editQuantity/AuthorEditQuantity.js @@ -18,7 +18,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react' import PropTypes from 'prop-types' import {getFieldProps, TextFieldWithHelp} from './StringEditQuantity' -import {CircularProgress, InputAdornment, makeStyles, Typography} from '@material-ui/core' +import {Box, CircularProgress, FormLabel, InputAdornment, makeStyles, Typography} from '@material-ui/core' import AutoComplete from '@material-ui/lab/Autocomplete' import {debounce} from 'lodash' import {fetchUsers} from '../uploads/EditMembersDialog' @@ -46,6 +46,7 @@ export const AuthorEditQuantity = React.memo((props) => { const [query, setQuery] = useState('') const [suggestions, setSuggestions] = useState([]) const [searching, setSearching] = useState(false) + const userOnly = useMemo(() => quantityDef.type?.type_kind === 'User', [quantityDef]) useEffect(() => { setAuthor(otherProps?.value) @@ -121,9 +122,9 @@ export const AuthorEditQuantity = React.memo((props) => { }, [otherFieldProps.value, suggestions]) return - + {!userOnly && {getFieldProps(quantityDef).label} - + } option ? (option.affiliation ? `${option.name} (${option.affiliation})` : option.name) : ''} @@ -146,67 +147,72 @@ export const AuthorEditQuantity = React.memo((props) => { margin='normal' fullWidth {...getFieldProps(quantityDef)} - label={'User'} + label={(userOnly ? getFieldProps(quantityDef).label : 'User account')} /> )} data-testid='user-edit-quantity' /> - handleChange('first_name', event.target.value)} - fullWidth - /> - handleChange('last_name', event.target.value)} - fullWidth - /> - handleChange('affiliation', event.target.value)} - fullWidth - /> - handleEmailChange(event.target.value)} - error={!isValidEmail(email)} - helperText={!isValidEmail(email) && 'The email is not valid!'} - fullWidth - /> - handleChange('affiliation_address', event.target.value)} - fullWidth - /> + {!userOnly && + + {'Or provide author information'} + + handleChange('first_name', event.target.value)} + fullWidth + /> + handleChange('last_name', event.target.value)} + fullWidth + /> + handleChange('affiliation', event.target.value)} + fullWidth + /> + handleEmailChange(event.target.value)} + error={!isValidEmail(email)} + helperText={!isValidEmail(email) && 'The email is not valid!'} + fullWidth + /> + handleChange('affiliation_address', event.target.value)} + fullWidth + /> + } }) AuthorEditQuantity.propTypes = { diff --git a/gui/src/components/editQuantity/EditQuantityExamples.js b/gui/src/components/editQuantity/EditQuantityExamples.js index 519bc6c87..5a9e6e0b8 100644 --- a/gui/src/components/editQuantity/EditQuantityExamples.js +++ b/gui/src/components/editQuantity/EditQuantityExamples.js @@ -98,6 +98,11 @@ export function EditQuantityExamples() { type_data: 'int' } + const user = { + type_kind: 'User', + type_data: 'User' + } + return @@ -410,6 +415,18 @@ export function EditQuantityExamples() { /> + + + + + { await waitFor(() => expect(redirectButton()).toBeInTheDocument()) }) -test('Test User in AuthorEditQuantity', async () => { +test('Test AuthorEditQuantity', async () => { await startAPI('tests.states.uploads.empty', 'tests/data/editquantity/user') render() -- GitLab From a6d7da1ec5d70ef338be9ff61cc7f8b875a7b97a Mon Sep 17 00:00:00 2001 From: mohammad Date: Fri, 23 Sep 2022 11:12:14 +0200 Subject: [PATCH 06/11] Remove the userEditQuantity everywhere --- nomad/datamodel/metainfo/eln/__init__.py | 7 +------ nomad/metainfo/metainfo.py | 5 +---- tests/metainfo/test_yaml_schema.py | 2 +- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/nomad/datamodel/metainfo/eln/__init__.py b/nomad/datamodel/metainfo/eln/__init__.py index 67d5e148c..d03f2e28c 100644 --- a/nomad/datamodel/metainfo/eln/__init__.py +++ b/nomad/datamodel/metainfo/eln/__init__.py @@ -113,13 +113,8 @@ class ElnActivityBaseSection(ElnBaseSection): description='A short consistent handle for the applied method.') user = Quantity( - type=user_reference, - description='A user registered in Nomad.', - a_eln=dict(component='UserEditQuantity')) - - author = Quantity( type=author_reference, - description='An author that may or not be a Nomad user.', + description='The corresponding user for the activity.', a_eln=dict(component='AuthorEditQuantity')) def normalize(self, archive, logger): diff --git a/nomad/metainfo/metainfo.py b/nomad/metainfo/metainfo.py index beded0de5..737c64f01 100644 --- a/nomad/metainfo/metainfo.py +++ b/nomad/metainfo/metainfo.py @@ -98,7 +98,7 @@ validElnComponents = { 'number': ['NumberEditQuantity', 'SliderEditQuantity'], 'datetime': ['DateTimeEditQuantity'], 'enum': ['EnumEditQuantity', 'AutocompleteEditQuantity', 'RadioEnumEditQuantity'], - 'user': ['UserEditQuantity'], + 'user': ['AuthorEditQuantity'], 'author': ['AuthorEditQuantity'], 'reference': ['ReferenceEditQuantity'] } @@ -606,9 +606,6 @@ class _QuantityType(DataType): if isinstance(value, Quantity): return QuantityReference(value) - if value.__name__ == 'UserReference' or value.__name__ == 'AuthorReference': - return value - if isinstance(value, MProxy): value.m_proxy_section = section value.m_proxy_quantity = quantity_def diff --git a/tests/metainfo/test_yaml_schema.py b/tests/metainfo/test_yaml_schema.py index cbaeabe7a..9c12920b9 100644 --- a/tests/metainfo/test_yaml_schema.py +++ b/tests/metainfo/test_yaml_schema.py @@ -245,7 +245,7 @@ sections: type: User m_annotations: eln: - component: UserEditQuantity + component: AuthorEditQuantity my_author: type: Author m_annotations: -- GitLab From d478b00c7018fcabafd4357d374deadc6a342e05 Mon Sep 17 00:00:00 2001 From: mohammad Date: Fri, 23 Sep 2022 15:52:52 +0200 Subject: [PATCH 07/11] handle email as a default value for user types --- gui/src/components/api.js | 4 +- .../editQuantity/AuthorEditQuantity.js | 49 ++++++++++++++----- .../components/uploads/EditMembersDialog.js | 2 +- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/gui/src/components/api.js b/gui/src/components/api.js index 39692b7a3..a2d5a4afc 100644 --- a/gui/src/components/api.js +++ b/gui/src/components/api.js @@ -306,9 +306,9 @@ class Api { } } - async getUsers(prefix) { + async getUsers(query) { // no loading indicator, because this is only used in the background of the edit dialog - return this.get('users', {prefix: prefix}, {noLoading: true}).then(response => response.data) + return this.get('users', query, {noLoading: true}).then(response => response.data) } async inviteUser(user) { diff --git a/gui/src/components/editQuantity/AuthorEditQuantity.js b/gui/src/components/editQuantity/AuthorEditQuantity.js index d387d8124..9bf759711 100644 --- a/gui/src/components/editQuantity/AuthorEditQuantity.js +++ b/gui/src/components/editQuantity/AuthorEditQuantity.js @@ -42,16 +42,13 @@ export const AuthorEditQuantity = React.memo((props) => { const {api} = useApi() const {raiseError} = useErrors() const [author, setAuthor] = useState(undefined) + const [user, setUser] = useState(undefined) const [email, setEmail] = useState(otherProps?.value?.email) const [query, setQuery] = useState('') const [suggestions, setSuggestions] = useState([]) const [searching, setSearching] = useState(false) const userOnly = useMemo(() => quantityDef.type?.type_kind === 'User', [quantityDef]) - useEffect(() => { - setAuthor(otherProps?.value) - }, [otherProps?.value]) - const searchUsers = useCallback((value) => { const newQuery = value.toLowerCase() if (!(newQuery.startsWith(query) && suggestions.length === 0) || query === '') { @@ -94,6 +91,40 @@ export const AuthorEditQuantity = React.memo((props) => { } }, [author, onChange]) + useEffect(() => { + const initUser = async (user_id) => { + if (user_id) { + const users = await api.getUsers({user_id: user_id}) + if (users.length > 0) { + setSuggestions(users) + setUser(users[0]) + } else { + setSuggestions([]) + setUser(undefined) + } + } else { + setUser(undefined) + setSuggestions([]) + } + } + + initUser(otherFieldProps.value?.user_id) + + if (typeof otherProps?.value === 'string' && otherProps.value.includes('@')) { + api.getUsers({email: otherProps.value}) + .then(users => { + if (users.length > 0) { + handleChange('user', users[0]) + setSuggestions(users[0]) + } else { + setSuggestions([]) + } + }) + } else { + setAuthor(otherProps?.value) + } + }, [api, handleChange, otherFieldProps.value, otherProps.value, quantityDef.default]) + const isValidEmail = useCallback((value) => value ? value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) : true, []) const handleEmailChange = useCallback((value) => { @@ -113,14 +144,6 @@ export const AuthorEditQuantity = React.memo((props) => { debouncedSearchUsers(value) }, [debouncedSearchUsers]) - const user = useMemo(() => { - let user - if (otherFieldProps.value?.user_id) { - user = suggestions.find(option => option.user_id === otherFieldProps.value.user_id) - } - return user || null - }, [otherFieldProps.value, suggestions]) - return {!userOnly && {getFieldProps(quantityDef).label} @@ -131,7 +154,7 @@ export const AuthorEditQuantity = React.memo((props) => { getOptionSelected={(option, value) => suggestions && value && value.user_id && option.user_id === value.user_id} onInputChange={handleUserInputChange} onChange={(event, value) => handleChange('user', value)} - value={user} + value={user && suggestions.some(option => option.user_id === user.user_id) ? user : null} renderInput={params => ( { return new Promise((resolve, reject) => { - api.getUsers(newQuery) + api.getUsers({prefix: newQuery}) .then(users => { const withQueryInName = users.filter(user => user.name.toLowerCase().indexOf(newQuery) !== -1) withQueryInName.sort((a, b) => { -- GitLab From 3955c15b036ef68e29d59d92cba8a7821c1b480e Mon Sep 17 00:00:00 2001 From: mohammad Date: Fri, 23 Sep 2022 17:09:21 +0200 Subject: [PATCH 08/11] Correct the GUI test --- .../components/editQuantity/EditQuantityExamples.spec.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gui/src/components/editQuantity/EditQuantityExamples.spec.js b/gui/src/components/editQuantity/EditQuantityExamples.spec.js index 0d7a12995..7e14af345 100644 --- a/gui/src/components/editQuantity/EditQuantityExamples.spec.js +++ b/gui/src/components/editQuantity/EditQuantityExamples.spec.js @@ -132,9 +132,12 @@ test('Test AuthorEditQuantity', async () => { render() // Wait to load the entry metadata, i.e. wait for some texts to appear - await screen.findByText('User') + await screen.findByText('User account') - const userField = screen.getByTestId('user-edit-quantity') + const userFields = screen.getAllByTestId('user-edit-quantity') + expect(userFields.length).toBe(2) + + const userField = userFields[1] const userFieldInput = within(userField).getByRole('textbox') userField.focus() // assign an incomplete value to the input field -- GitLab From 61d133364d1b5627358ebdfea5b692309f423b36 Mon Sep 17 00:00:00 2001 From: mohammad Date: Tue, 27 Sep 2022 15:45:52 +0200 Subject: [PATCH 09/11] move eln validation to data.py --- nomad/datamodel/data.py | 97 ++++++++++++++++++++++++++- nomad/metainfo/metainfo.py | 104 ++--------------------------- tests/metainfo/test_yaml_schema.py | 3 +- 3 files changed, 102 insertions(+), 102 deletions(-) diff --git a/nomad/datamodel/data.py b/nomad/datamodel/data.py index b39519906..0b038ac80 100644 --- a/nomad/datamodel/data.py +++ b/nomad/datamodel/data.py @@ -20,11 +20,33 @@ import os.path from typing import Any from cachetools import cached, TTLCache -from ..metainfo.metainfo import predefined_datatypes +from ..metainfo.metainfo import predefined_datatypes, types_num_python, types_num_numpy, _Datetime, MEnum, Reference from nomad import metainfo, config from nomad.metainfo.pydantic_extension import PydanticModel from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_entry_type +validElnTypes = { + 'str': ['str'], + 'bool': ['bool'], + 'number': [x.__name__ for x in types_num_python] + [f'np.{x.__name__}' for x in types_num_numpy], + 'datetime': ['Datetime'], + 'enum': ['{type_kind: Enum, type_data: [Operator, Responsible_person]}'], + 'user': ['User'], + 'author': ['Author'], + 'reference': [''] +} + +validElnComponents = { + 'str': ['StringEditQuantity', 'FileEditQuantity', 'RichTextEditQuantity', 'EnumEditQuantity'], + 'bool': ['BoolEditQuantity'], + 'number': ['NumberEditQuantity', 'SliderEditQuantity'], + 'datetime': ['DateTimeEditQuantity'], + 'enum': ['EnumEditQuantity', 'AutocompleteEditQuantity', 'RadioEnumEditQuantity'], + 'user': ['AuthorEditQuantity'], + 'author': ['AuthorEditQuantity'], + 'reference': ['ReferenceEditQuantity'] +} + class ArchiveSection(metainfo.MSection): ''' @@ -44,6 +66,79 @@ class ArchiveSection(metainfo.MSection): ''' pass + @classmethod + def __init_cls__(cls): + super().__init_cls__() + + def assert_component(component_name, quantity_name, quantity_type, accepted_components): + assert component_name in accepted_components, \ + 'The component `%s` is not compatible with the quantity `%s` of the type `%s`. Accepted components: %s.' \ + % (component_name, quantity_name, quantity_type, ', '.join(accepted_components)) + + if hasattr(cls, 'description'): + definition = cls.description + if definition.m_annotations and 'eln' in definition.m_annotations \ + and definition.m_annotations['eln'] and 'component' in definition.m_annotations['eln']: + component = definition.m_annotations['eln']['component'] + if component: + if isinstance(definition.type, type): + if definition.type.__name__ == 'str': + assert_component( + component, definition.name, definition.type.__name__, + validElnComponents['str'] + ) + elif definition.type.__name__ == 'bool': + assert_component( + component, definition.name, definition.type.__name__, validElnComponents['bool'] + ) + elif definition.type in types_num_python: + assert_component( + component, definition.name, definition.type.__name__, + validElnComponents['number'] + ) + elif definition.type in types_num_numpy: + assert_component( + component, definition.name, f'np.{definition.type.__name__}', + validElnComponents['number'] + ) + elif definition.type.__name__ == 'User': + assert_component( + component, definition.name, definition.type.__name__, + validElnComponents['user'] + ) + elif definition.type.__name__ == 'Author': + assert_component( + component, definition.name, definition.type.__name__, + validElnComponents['author'] + ) + elif isinstance(definition.type, _Datetime): + assert_component( + component, definition.name, type(definition.type).__name__, + validElnComponents['datetime'] + ) + elif isinstance(definition.type, MEnum): + assert_component( + component, definition.name, type(definition.type).__name__, + validElnComponents['enum'] + ) + elif isinstance(definition.type, Reference): + target_class = definition.type.target_section_def.section_cls + if target_class.__name__ == 'User': + assert_component( + component, definition.name, target_class.__name__, + validElnComponents['user'] + ) + elif target_class.__name__ == 'Author': + assert_component( + component, definition.name, target_class.__name__, + validElnComponents['author'] + ) + else: + assert_component( + component, definition.name, type(definition.type).__name__, + validElnComponents['reference'] + ) + class EntryData(ArchiveSection): ''' diff --git a/nomad/metainfo/metainfo.py b/nomad/metainfo/metainfo.py index 737c64f01..5c6575161 100644 --- a/nomad/metainfo/metainfo.py +++ b/nomad/metainfo/metainfo.py @@ -73,36 +73,13 @@ _types_int = _types_int_python | _types_int_numpy _types_float_numpy = {np.float16, np.float32, np.float64} _types_float_python = {float} _types_float = _types_float_python | _types_float_numpy -_types_num_numpy = _types_int_numpy | _types_float_numpy -_types_num_python = _types_int_python | _types_float_python -_types_num = _types_num_python | _types_num_numpy +types_num_numpy = _types_int_numpy | _types_float_numpy +types_num_python = _types_int_python | _types_float_python +_types_num = types_num_python | types_num_numpy _types_str_numpy = {np.str_} _types_bool_numpy = {np.bool_} -_types_numpy = _types_num_numpy | _types_str_numpy | _types_bool_numpy +_types_numpy = types_num_numpy | _types_str_numpy | _types_bool_numpy _delta_symbols = {'delta_', 'Δ'} - -validElnTypes = { - 'str': ['str'], - 'bool': ['bool'], - 'number': [x.__name__ for x in _types_num_python] + [f'np.{x.__name__}' for x in _types_num_numpy], - 'datetime': ['Datetime'], - 'enum': ['{type_kind: Enum, type_data: [Operator, Responsible_person]}'], - 'user': ['User'], - 'author': ['Author'], - 'reference': [''] -} - -validElnComponents = { - 'str': ['StringEditQuantity', 'FileEditQuantity', 'RichTextEditQuantity', 'EnumEditQuantity'], - 'bool': ['BoolEditQuantity'], - 'number': ['NumberEditQuantity', 'SliderEditQuantity'], - 'datetime': ['DateTimeEditQuantity'], - 'enum': ['EnumEditQuantity', 'AutocompleteEditQuantity', 'RadioEnumEditQuantity'], - 'user': ['AuthorEditQuantity'], - 'author': ['AuthorEditQuantity'], - 'reference': ['ReferenceEditQuantity'] -} - _unset_value = '__UNSET__' @@ -3409,7 +3386,7 @@ class Quantity(Property): is_primitive = not self.derived is_primitive = is_primitive and len(self.shape) <= 1 is_primitive = is_primitive and self.type in [str, bool, float, int] - is_primitive = is_primitive and self.type not in _types_num_numpy + is_primitive = is_primitive and self.type not in types_num_numpy if is_primitive: self._default = self.default self._name = self.name @@ -4039,77 +4016,6 @@ class Section(Definition): f'Alias {alias} of {definition} in {definition.m_parent} already exists in {self}.' names.add(alias) - @constraint - def compatible_eln_annotation(self): - def assert_component(component_name, quantity_name, quantity_type, accepted_components): - assert component_name in accepted_components, \ - 'The component `%s` is not compatible with the quantity `%s` of the type `%s`. Accepted components: %s.' \ - % (component_name, quantity_name, quantity_type, ', '.join(accepted_components)) - - for def_list in [self.quantities, self.sub_sections]: - for definition in def_list: - if definition.m_annotations and 'eln' in definition.m_annotations \ - and definition.m_annotations['eln'] and 'component' in definition.m_annotations['eln']: - component = definition.m_annotations['eln']['component'] - if component: - if isinstance(definition.type, type): - if definition.type.__name__ == 'str': - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['str'] - ) - elif definition.type.__name__ == 'bool': - assert_component( - component, definition.name, definition.type.__name__, validElnComponents['bool'] - ) - elif definition.type in _types_num_python: - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['number'] - ) - elif definition.type in _types_num_numpy: - assert_component( - component, definition.name, f'np.{definition.type.__name__}', - validElnComponents['number'] - ) - elif definition.type.__name__ == 'User': - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['user'] - ) - elif definition.type.__name__ == 'Author': - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['author'] - ) - elif isinstance(definition.type, _Datetime): - assert_component( - component, definition.name, type(definition.type).__name__, - validElnComponents['datetime'] - ) - elif isinstance(definition.type, MEnum): - assert_component( - component, definition.name, type(definition.type).__name__, - validElnComponents['enum'] - ) - elif isinstance(definition.type, Reference): - target_class = definition.type.target_section_def.section_cls - if target_class.__name__ == 'User': - assert_component( - component, definition.name, target_class.__name__, - validElnComponents['user'] - ) - elif target_class.__name__ == 'Author': - assert_component( - component, definition.name, target_class.__name__, - validElnComponents['author'] - ) - else: - assert_component( - component, definition.name, type(definition.type).__name__, - validElnComponents['reference'] - ) - @constraint def resolved_base_sections(self): for base_section in self.base_sections: diff --git a/tests/metainfo/test_yaml_schema.py b/tests/metainfo/test_yaml_schema.py index 9c12920b9..da1b37182 100644 --- a/tests/metainfo/test_yaml_schema.py +++ b/tests/metainfo/test_yaml_schema.py @@ -2,8 +2,7 @@ import numpy as np # pylint: disable=unused-import import pytest import yaml -from nomad.datamodel.data import UserReference, AuthorReference -from nomad.metainfo.metainfo import validElnComponents, validElnTypes +from nomad.datamodel.data import UserReference, AuthorReference, validElnComponents, validElnTypes from nomad.utils import strip from nomad.metainfo import Package, MSection, Quantity, Reference, SubSection, Section, MProxy, MetainfoError -- GitLab From 47006972625bab775f7d66665e12e5de37ff6ca5 Mon Sep 17 00:00:00 2001 From: mohammad Date: Thu, 29 Sep 2022 17:00:14 +0200 Subject: [PATCH 10/11] Add constraints to the ArchiveSection class --- nomad/datamodel/data.py | 125 ++++++++++++++--------------- nomad/metainfo/metainfo.py | 24 ++++++ tests/metainfo/test_yaml_schema.py | 5 +- 3 files changed, 89 insertions(+), 65 deletions(-) diff --git a/nomad/datamodel/data.py b/nomad/datamodel/data.py index 0b038ac80..e9d8eaf8e 100644 --- a/nomad/datamodel/data.py +++ b/nomad/datamodel/data.py @@ -20,7 +20,8 @@ import os.path from typing import Any from cachetools import cached, TTLCache -from ..metainfo.metainfo import predefined_datatypes, types_num_python, types_num_numpy, _Datetime, MEnum, Reference +from ..metainfo.metainfo import predefined_datatypes, types_num_python, types_num_numpy, _Datetime, MEnum, Reference, \ + constraint from nomad import metainfo, config from nomad.metainfo.pydantic_extension import PydanticModel from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_entry_type @@ -53,6 +54,7 @@ class ArchiveSection(metainfo.MSection): Base class for sections in a NOMAD archive. Provides a framework for custom section normalization via the `normalize` function. ''' + def normalize(self, archive, logger): ''' Is called during entry normalization. If you overwrite this with custom @@ -66,78 +68,75 @@ class ArchiveSection(metainfo.MSection): ''' pass - @classmethod - def __init_cls__(cls): - super().__init_cls__() - + @constraint + def compatibility(self): def assert_component(component_name, quantity_name, quantity_type, accepted_components): assert component_name in accepted_components, \ 'The component `%s` is not compatible with the quantity `%s` of the type `%s`. Accepted components: %s.' \ % (component_name, quantity_name, quantity_type, ', '.join(accepted_components)) - if hasattr(cls, 'description'): - definition = cls.description - if definition.m_annotations and 'eln' in definition.m_annotations \ - and definition.m_annotations['eln'] and 'component' in definition.m_annotations['eln']: - component = definition.m_annotations['eln']['component'] - if component: - if isinstance(definition.type, type): - if definition.type.__name__ == 'str': - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['str'] - ) - elif definition.type.__name__ == 'bool': - assert_component( - component, definition.name, definition.type.__name__, validElnComponents['bool'] - ) - elif definition.type in types_num_python: - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['number'] - ) - elif definition.type in types_num_numpy: - assert_component( - component, definition.name, f'np.{definition.type.__name__}', - validElnComponents['number'] - ) - elif definition.type.__name__ == 'User': - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['user'] - ) - elif definition.type.__name__ == 'Author': - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['author'] - ) - elif isinstance(definition.type, _Datetime): + definition = self + if definition.m_annotations and 'eln' in definition.m_annotations \ + and definition.m_annotations['eln'] and 'component' in definition.m_annotations['eln']: + component = definition.m_annotations['eln']['component'] + if component: + if isinstance(definition.type, type): + if definition.type.__name__ == 'str': assert_component( - component, definition.name, type(definition.type).__name__, - validElnComponents['datetime'] + component, definition.name, definition.type.__name__, + validElnComponents['str'] + ) + elif definition.type.__name__ == 'bool': + assert_component( + component, definition.name, definition.type.__name__, validElnComponents['bool'] + ) + elif definition.type in types_num_python: + assert_component( + component, definition.name, definition.type.__name__, + validElnComponents['number'] + ) + elif definition.type in types_num_numpy: + assert_component( + component, definition.name, f'np.{definition.type.__name__}', + validElnComponents['number'] + ) + elif definition.type.__name__ == 'User': + assert_component( + component, definition.name, definition.type.__name__, + validElnComponents['user'] + ) + elif definition.type.__name__ == 'Author': + assert_component( + component, definition.name, definition.type.__name__, + validElnComponents['author'] + ) + elif isinstance(definition.type, _Datetime): + assert_component( + component, definition.name, type(definition.type).__name__, + validElnComponents['datetime'] + ) + elif isinstance(definition.type, MEnum): + assert_component( + component, definition.name, type(definition.type).__name__, + validElnComponents['enum'] + ) + elif isinstance(definition.type, Reference): + target_class = definition.type.target_section_def.section_cls + if target_class.__name__ == 'User': + assert_component( + component, definition.name, target_class.__name__, + validElnComponents['user'] + ) + elif target_class.__name__ == 'Author': + assert_component( + component, definition.name, target_class.__name__, + validElnComponents['author'] ) - elif isinstance(definition.type, MEnum): + else: assert_component( component, definition.name, type(definition.type).__name__, - validElnComponents['enum'] + validElnComponents['reference'] ) - elif isinstance(definition.type, Reference): - target_class = definition.type.target_section_def.section_cls - if target_class.__name__ == 'User': - assert_component( - component, definition.name, target_class.__name__, - validElnComponents['user'] - ) - elif target_class.__name__ == 'Author': - assert_component( - component, definition.name, target_class.__name__, - validElnComponents['author'] - ) - else: - assert_component( - component, definition.name, type(definition.type).__name__, - validElnComponents['reference'] - ) class EntryData(ArchiveSection): diff --git a/nomad/metainfo/metainfo.py b/nomad/metainfo/metainfo.py index 5c6575161..b7d39e21c 100644 --- a/nomad/metainfo/metainfo.py +++ b/nomad/metainfo/metainfo.py @@ -2736,6 +2736,24 @@ class MSection(metaclass=MObjectMeta): # TODO find a way to make this a subclas ''' Evaluates all constraints and shapes of this section and returns a list of errors. ''' errors: List[str] = [] warnings: List[str] = [] + if self.m_parent and hasattr(self.m_parent, 'all_base_sections'): + for base_sections in self.m_parent.all_base_sections: + for constraint_name in base_sections.constraints: + constraint = getattr(base_sections.section_cls, constraint_name, None) + if constraint is None: + raise MetainfoError( + f'Could not find implementation for constraint {constraint_name} of section {self.m_def}.') + try: + constraint(self) + except AssertionError as e: + error_str = str(e).strip() + if error_str == '': + error_str = f'Constraint {constraint_name} violated.' + if getattr(constraint, 'm_warning', False): + warnings.append(error_str) + else: + errors.append(error_str) + for constraint_name in self.m_def.constraints: constraint = getattr(self, constraint_name, None) if constraint is None: @@ -3070,6 +3088,12 @@ class Definition(MSection): a class context, this method must be called manually on all definitions. ''' + # for base_section in self.all_base_sections: + # for constraint in base_section.constraints: + # constraints.add(constraint) + # for event_handler in base_section.event_handlers: + # event_handlers.add(event_handler) + # initialize definition annotations for annotation in self.m_get_annotations(DefinitionAnnotation, as_list=True): annotation.init_annotation(self) diff --git a/tests/metainfo/test_yaml_schema.py b/tests/metainfo/test_yaml_schema.py index da1b37182..f06726c4a 100644 --- a/tests/metainfo/test_yaml_schema.py +++ b/tests/metainfo/test_yaml_schema.py @@ -197,7 +197,7 @@ def test_datatype_component_annotations(eln_type, eln_component): m_def: 'nomad.metainfo.metainfo.Package' sections: Sample: - base_section: 'nomad.datamodel.metainfo.measurements.Sample' + base_section: 'nomad.datamodel.data.EntryData' quantities: sample_id: type: str @@ -205,6 +205,7 @@ def test_datatype_component_annotations(eln_type, eln_component): eln: component: StringEditQuantity Process: + base_section: 'nomad.datamodel.data.EntryData' quantities: quantity_name: type: quantity_type @@ -230,7 +231,7 @@ def test_datatype_component_annotations(eln_type, eln_component): type_name = type(quantity.type).__name__ package.__init_metainfo__() assert isinstance(exception.value, MetainfoError) - assert exception.value.args[0] == 'One constraint was violated: The component `%s` is not compatible with the quantity `%s` of the type `%s`. Accepted components: %s (there are 0 more violations)' \ + assert exception.value.args[0] == 'One constraint was violated: The component `%s` is not compatible with the quantity `%s` of the type `%s`. Accepted components: %s (there are 1 more violations)' \ % (eln_component, 'quantity_name', type_name, ', '.join(validElnComponents[eln_type])) -- GitLab From bc217af73073a9663f51c30fd75bc4b29094c601 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen Date: Fri, 30 Sep 2022 11:41:24 +0200 Subject: [PATCH 11/11] Refactored eln schema validation. --- nomad/datamodel/data.py | 95 +----------------- nomad/datamodel/metainfo/eln/annotations.py | 84 ++++++++++++++++ nomad/metainfo/metainfo.py | 8 ++ tests/data/datamodel/eln.archive.yaml | 9 ++ tests/datamodel/test_schema.py | 103 +++++++++++++++++++- tests/metainfo/test_yaml_schema.py | 78 --------------- 6 files changed, 204 insertions(+), 173 deletions(-) create mode 100644 nomad/datamodel/metainfo/eln/annotations.py create mode 100644 tests/data/datamodel/eln.archive.yaml diff --git a/nomad/datamodel/data.py b/nomad/datamodel/data.py index e9d8eaf8e..610698dea 100644 --- a/nomad/datamodel/data.py +++ b/nomad/datamodel/data.py @@ -20,34 +20,11 @@ import os.path from typing import Any from cachetools import cached, TTLCache -from ..metainfo.metainfo import predefined_datatypes, types_num_python, types_num_numpy, _Datetime, MEnum, Reference, \ - constraint +from ..metainfo.metainfo import predefined_datatypes from nomad import metainfo, config from nomad.metainfo.pydantic_extension import PydanticModel from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_entry_type -validElnTypes = { - 'str': ['str'], - 'bool': ['bool'], - 'number': [x.__name__ for x in types_num_python] + [f'np.{x.__name__}' for x in types_num_numpy], - 'datetime': ['Datetime'], - 'enum': ['{type_kind: Enum, type_data: [Operator, Responsible_person]}'], - 'user': ['User'], - 'author': ['Author'], - 'reference': [''] -} - -validElnComponents = { - 'str': ['StringEditQuantity', 'FileEditQuantity', 'RichTextEditQuantity', 'EnumEditQuantity'], - 'bool': ['BoolEditQuantity'], - 'number': ['NumberEditQuantity', 'SliderEditQuantity'], - 'datetime': ['DateTimeEditQuantity'], - 'enum': ['EnumEditQuantity', 'AutocompleteEditQuantity', 'RadioEnumEditQuantity'], - 'user': ['AuthorEditQuantity'], - 'author': ['AuthorEditQuantity'], - 'reference': ['ReferenceEditQuantity'] -} - class ArchiveSection(metainfo.MSection): ''' @@ -68,76 +45,6 @@ class ArchiveSection(metainfo.MSection): ''' pass - @constraint - def compatibility(self): - def assert_component(component_name, quantity_name, quantity_type, accepted_components): - assert component_name in accepted_components, \ - 'The component `%s` is not compatible with the quantity `%s` of the type `%s`. Accepted components: %s.' \ - % (component_name, quantity_name, quantity_type, ', '.join(accepted_components)) - - definition = self - if definition.m_annotations and 'eln' in definition.m_annotations \ - and definition.m_annotations['eln'] and 'component' in definition.m_annotations['eln']: - component = definition.m_annotations['eln']['component'] - if component: - if isinstance(definition.type, type): - if definition.type.__name__ == 'str': - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['str'] - ) - elif definition.type.__name__ == 'bool': - assert_component( - component, definition.name, definition.type.__name__, validElnComponents['bool'] - ) - elif definition.type in types_num_python: - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['number'] - ) - elif definition.type in types_num_numpy: - assert_component( - component, definition.name, f'np.{definition.type.__name__}', - validElnComponents['number'] - ) - elif definition.type.__name__ == 'User': - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['user'] - ) - elif definition.type.__name__ == 'Author': - assert_component( - component, definition.name, definition.type.__name__, - validElnComponents['author'] - ) - elif isinstance(definition.type, _Datetime): - assert_component( - component, definition.name, type(definition.type).__name__, - validElnComponents['datetime'] - ) - elif isinstance(definition.type, MEnum): - assert_component( - component, definition.name, type(definition.type).__name__, - validElnComponents['enum'] - ) - elif isinstance(definition.type, Reference): - target_class = definition.type.target_section_def.section_cls - if target_class.__name__ == 'User': - assert_component( - component, definition.name, target_class.__name__, - validElnComponents['user'] - ) - elif target_class.__name__ == 'Author': - assert_component( - component, definition.name, target_class.__name__, - validElnComponents['author'] - ) - else: - assert_component( - component, definition.name, type(definition.type).__name__, - validElnComponents['reference'] - ) - class EntryData(ArchiveSection): ''' diff --git a/nomad/datamodel/metainfo/eln/annotations.py b/nomad/datamodel/metainfo/eln/annotations.py new file mode 100644 index 000000000..e0e512d6b --- /dev/null +++ b/nomad/datamodel/metainfo/eln/annotations.py @@ -0,0 +1,84 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from nomad.metainfo.metainfo import types_num_numpy, types_num_python, _Datetime, MEnum, Reference + + +validElnTypes = { + 'str': ['str'], + 'bool': ['bool'], + 'number': [x.__name__ for x in types_num_python] + [f'np.{x.__name__}' for x in types_num_numpy], # type: ignore + 'datetime': ['Datetime'], + 'enum': ['{type_kind: Enum, type_data: [Operator, Responsible_person]}'], + 'user': ['User'], + 'author': ['Author'], + 'reference': [''] +} + +validElnComponents = { + 'str': ['StringEditQuantity', 'FileEditQuantity', 'RichTextEditQuantity', 'EnumEditQuantity'], + 'bool': ['BoolEditQuantity'], + 'number': ['NumberEditQuantity', 'SliderEditQuantity'], + 'datetime': ['DateTimeEditQuantity'], + 'enum': ['EnumEditQuantity', 'AutocompleteEditQuantity', 'RadioEnumEditQuantity'], + 'user': ['AuthorEditQuantity'], + 'author': ['AuthorEditQuantity'], + 'reference': ['ReferenceEditQuantity'] +} + + +def validate_eln_quantity_annotations(quantity): + def assert_component(component_name, quantity_name, quantity_type, accepted_components): + assert component_name in accepted_components, ( + f'The component {component_name} is not compatible with the quantity ' + f'{quantity_name} of the type {quantity_type}. ' + f'Accepted components: {", ".join(accepted_components)}.') + + if 'eln' not in quantity.m_annotations: + return + + component = quantity.m_annotations['eln'].get('component', False) + assert component, 'Quantity ELN annotation need to define a component' + + mtype = quantity.type + name = quantity.name + if isinstance(mtype, type): + if mtype.__name__ == 'str': + assert_component(component, name, mtype.__name__, validElnComponents['str']) + elif mtype.__name__ == 'bool': + assert_component(component, name, mtype.__name__, validElnComponents['bool']) + elif mtype in types_num_python: + assert_component(component, name, mtype.__name__, validElnComponents['number']) + elif mtype in types_num_numpy: + assert_component(component, name, f'np.{mtype.__name__}', validElnComponents['number']) + elif mtype.__name__ == 'User': + assert_component(component, name, mtype.__name__, validElnComponents['user']) + elif mtype.__name__ == 'Author': + assert_component(component, name, mtype.__name__, validElnComponents['author']) + elif isinstance(mtype, _Datetime): + assert_component(component, name, type(mtype).__name__, validElnComponents['datetime']) + elif isinstance(mtype, MEnum): + assert_component(component, name, type(mtype).__name__, validElnComponents['enum']) + elif isinstance(mtype, Reference): + target_class = mtype.target_section_def.section_cls + if target_class.__name__ == 'User': + assert_component(component, name, target_class.__name__, validElnComponents['user']) + elif target_class.__name__ == 'Author': + assert_component(component, name, target_class.__name__, validElnComponents['author']) + else: + assert_component(component, name, type(mtype).__name__, validElnComponents['reference']) diff --git a/nomad/metainfo/metainfo.py b/nomad/metainfo/metainfo.py index b7d39e21c..6482f5aad 100644 --- a/nomad/metainfo/metainfo.py +++ b/nomad/metainfo/metainfo.py @@ -3511,6 +3511,14 @@ class Quantity(Property): assert self.type in _types_numpy, \ f'Higher dimensional quantities ({self}) need a dtype and will be treated as numpy arrays.' + @constraint + def annotations_are_valid(self): + # TODO this should be replaced with a proper mechanism for defining and + # validating annotation types + if 'eln' in self.m_annotations: + from nomad.datamodel.metainfo.eln.annotations import validate_eln_quantity_annotations + validate_eln_quantity_annotations(self) + def _hash_seed(self) -> str: ''' Generate a unique representation for this quantity. diff --git a/tests/data/datamodel/eln.archive.yaml b/tests/data/datamodel/eln.archive.yaml new file mode 100644 index 000000000..69b096d1c --- /dev/null +++ b/tests/data/datamodel/eln.archive.yaml @@ -0,0 +1,9 @@ +definitions: + sections: + TestSection: + quantities: + my_quantity: + type: str + m_annotations: + eln: + component: NumberEditComponent \ No newline at end of file diff --git a/tests/datamodel/test_schema.py b/tests/datamodel/test_schema.py index de6038ba3..0f559809a 100644 --- a/tests/datamodel/test_schema.py +++ b/tests/datamodel/test_schema.py @@ -16,17 +16,25 @@ # limitations under the License. # +import os.path +import pytest + +from nomad.metainfo import MetainfoError from nomad.datamodel.context import ServerContext from nomad.datamodel.datamodel import EntryArchive, EntryMetadata +from nomad.datamodel.data import UserReference, AuthorReference +from nomad.datamodel.metainfo.eln.annotations import validElnTypes, validElnComponents from nomad.parsing.parser import ArchiveParser from nomad.processing.data import Upload +from nomad.utils import get_logger, strip from tests.normalizing.conftest import run_normalize from tests.test_files import create_test_upload_files +from tests.metainfo.test_yaml_schema import yaml_to_package def test_schema_processing(raw_files, no_warn): - directory = 'tests/data/datamodel' + directory = os.path.join(os.path.dirname(__file__), '../data/datamodel') mainfile = 'schema.archive.json' # create upload with example files @@ -45,3 +53,96 @@ def test_schema_processing(raw_files, no_warn): # assert archive assert len(test_archive.definitions.section_definitions) == 1 assert test_archive.metadata.entry_type == 'Schema' + + +def test_eln_annotation_validation_parsing(raw_files, caplog): + mainfile = os.path.join(os.path.dirname(__file__), '../data/datamodel/eln.archive.yaml') + + # parse + parser = ArchiveParser() + test_archive = EntryArchive(metadata=EntryMetadata()) + with pytest.raises(Exception): + parser.parse(mainfile, test_archive, get_logger(__name__)) + + has_error = False + for record in caplog.get_records(when='call'): + if record.levelname == 'ERROR': + has_error = True + + assert has_error + + +@pytest.mark.parametrize("eln_type", validElnTypes.keys()) +@pytest.mark.parametrize("eln_component", sum(validElnComponents.values(), [])) +def test_eln_annotation_validation(eln_type, eln_component): + base_schema = strip(''' + m_def: 'nomad.metainfo.metainfo.Package' + sections: + Sample: + base_section: 'nomad.datamodel.data.EntryData' + quantities: + sample_id: + type: str + m_annotations: + eln: + component: StringEditQuantity + Process: + base_section: 'nomad.datamodel.data.EntryData' + quantities: + quantity_name: + type: quantity_type + m_annotations: + eln: + component: eln_component + ''') + + for quantity_type in validElnTypes[eln_type]: + if eln_type == 'reference': + yaml_schema = base_schema.replace("quantity_type", "'#/Sample'").replace("eln_component", eln_component) + else: + yaml_schema = base_schema.replace("quantity_type", quantity_type).replace("eln_component", eln_component) + + if eln_component not in validElnComponents[eln_type]: + package = yaml_to_package(yaml_schema) + type_name = quantity_type + if eln_type in ['number', 'datetime', 'enum', 'reference']: + quantity = package['section_definitions'][1]['quantities'][0] + if type(quantity.type).__name__ != 'type': + type_name = type(quantity.type).__name__ + with pytest.raises(Exception) as exception: + package.__init_metainfo__() + + assert isinstance(exception.value, MetainfoError) + assert exception.value.args[0] == ( + f'One constraint was violated: The component {eln_component} ' + f'is not compatible with the quantity quantity_name of the type {type_name}. ' + f'Accepted components: {", ".join(validElnComponents[eln_type])} ' + f'(there are 0 more violations)') + + +def test_user_author_yaml_deserialization(): + des_m_package = yaml_to_package(strip(''' + m_def: 'nomad.metainfo.metainfo.Package' + sections: + Sample: + base_section: 'nomad.datamodel.metainfo.measurements.Sample' + quantities: + my_user: + type: User + m_annotations: + eln: + component: AuthorEditQuantity + my_author: + type: Author + m_annotations: + eln: + component: AuthorEditQuantity + ''')) + des_sample = des_m_package['section_definitions'][0] + des_my_user = des_sample.quantities[0] + des_my_author = des_sample.quantities[1] + + assert des_my_user.name == 'my_user' + assert des_my_author.name == 'my_author' + assert isinstance(des_my_user.type, UserReference) + assert isinstance(des_my_author.type, AuthorReference) diff --git a/tests/metainfo/test_yaml_schema.py b/tests/metainfo/test_yaml_schema.py index f06726c4a..0e924616a 100644 --- a/tests/metainfo/test_yaml_schema.py +++ b/tests/metainfo/test_yaml_schema.py @@ -2,9 +2,7 @@ import numpy as np # pylint: disable=unused-import import pytest import yaml -from nomad.datamodel.data import UserReference, AuthorReference, validElnComponents, validElnTypes from nomad.utils import strip - from nomad.metainfo import Package, MSection, Quantity, Reference, SubSection, Section, MProxy, MetainfoError m_package = Package() @@ -188,79 +186,3 @@ def test_sub_section_tree(): ''') assert yaml.m_to_dict() == reference.m_to_dict() - - -@pytest.mark.parametrize("eln_type", validElnTypes.keys()) -@pytest.mark.parametrize("eln_component", sum(validElnComponents.values(), [])) -def test_datatype_component_annotations(eln_type, eln_component): - base_schema = ''' - m_def: 'nomad.metainfo.metainfo.Package' - sections: - Sample: - base_section: 'nomad.datamodel.data.EntryData' - quantities: - sample_id: - type: str - m_annotations: - eln: - component: StringEditQuantity - Process: - base_section: 'nomad.datamodel.data.EntryData' - quantities: - quantity_name: - type: quantity_type - m_annotations: - eln: - component: eln_component - ''' - - for quantity_type in validElnTypes[eln_type]: - if eln_type == 'reference': - yaml_schema = base_schema.replace("quantity_type", "'#/Sample'").replace("eln_component", eln_component) - else: - yaml_schema = base_schema.replace("quantity_type", quantity_type).replace("eln_component", eln_component) - - if eln_component not in validElnComponents[eln_type]: - with pytest.raises(Exception) as exception: - package = yaml_to_package(yaml_schema) - type_name = quantity_type - if eln_type == 'number' or eln_type == 'datetime' or eln_type == 'enum' or eln_type == 'reference': - process = next(filter(lambda section: section['name'] == 'Process', package['section_definitions']), None) - quantity = process['quantities'][0] - if type(quantity.type).__name__ != 'type': - type_name = type(quantity.type).__name__ - package.__init_metainfo__() - assert isinstance(exception.value, MetainfoError) - assert exception.value.args[0] == 'One constraint was violated: The component `%s` is not compatible with the quantity `%s` of the type `%s`. Accepted components: %s (there are 1 more violations)' \ - % (eln_component, 'quantity_name', type_name, ', '.join(validElnComponents[eln_type])) - - -yaml_schema_user_author = strip(''' -m_def: 'nomad.metainfo.metainfo.Package' -sections: - Sample: - base_section: 'nomad.datamodel.metainfo.measurements.Sample' - quantities: - my_user: - type: User - m_annotations: - eln: - component: AuthorEditQuantity - my_author: - type: Author - m_annotations: - eln: - component: AuthorEditQuantity -''') - - -def test_user_author_yaml_deserialization(): - des_m_package = yaml_to_package(yaml_schema_user_author) - des_sample = des_m_package['section_definitions'][0] - des_my_user = des_sample.quantities[0] - des_my_author = des_sample.quantities[1] - - assert des_my_user.name == 'my_user' - assert des_my_author.name == 'my_author' - assert isinstance(des_my_user.type, UserReference) - assert isinstance(des_my_author.type, AuthorReference) -- GitLab