diff --git a/gui/src/components/About.js b/gui/src/components/About.js index 4f1179d7345bc7914f4e8687cfd878aaabff45dc..673b50725728cd04621665339ec165ea8f84f8f6 100644 --- a/gui/src/components/About.js +++ b/gui/src/components/About.js @@ -15,18 +15,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useContext, useLayoutEffect, useRef, useCallback, useEffect, useState } from 'react' +import React, { useLayoutEffect, useRef, useCallback, useEffect, useState } from 'react' import {ReactComponent as AboutSvg} from './about.svg' import PropTypes from 'prop-types' import Markdown from './Markdown' import { appBase, debug, consent, aitoolkitEnabled, encyclopediaEnabled } from '../config' -import { apiContext } from './api' import packageJson from '../../package.json' import { domainData } from './domainData' import { Grid, Card, CardContent, Typography, makeStyles, Link, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core' import { Link as RouterLink, useHistory } from 'react-router-dom' import tutorials from '../toolkitMetadata' import parserMetadata from '../parserMetadata' +import { useApi } from './api' function CodeInfo({code, ...props}) { if (!code) { @@ -166,7 +166,7 @@ const useStyles = makeStyles(theme => ({ export default function About() { const classes = useStyles() - const {info} = useContext(apiContext) + const {info} = useApi() const svg = useRef() const history = useHistory() diff --git a/gui/src/components/App.js b/gui/src/components/App.js index 844222d0aa258caae7857db1c17bd67748300649..2be96ee1e45ca2524ef4de14f598446cdf3f0814 100644 --- a/gui/src/components/App.js +++ b/gui/src/components/App.js @@ -28,7 +28,6 @@ import { nomadTheme, matomoEnabled, matomoUrl, matomoSiteId, keycloakBase, keycl import Keycloak from 'keycloak-js' import { KeycloakProvider } from 'react-keycloak' import { MuiThemeProvider } from '@material-ui/core/styles' -import { ApiProvider } from './api' import { ErrorSnacks, ErrorBoundary } from './errors' import Navigation from './nav/Navigation' @@ -57,9 +56,7 @@ export default function App() { <ErrorSnacks> <ErrorBoundary> <RecoilRoot> - <ApiProvider> - <Navigation /> - </ApiProvider> + <Navigation /> </RecoilRoot> </ErrorBoundary> </ErrorSnacks> diff --git a/gui/src/components/DatasetPage.js b/gui/src/components/DatasetPage.js index 143a63a1e420fc7eccdbf5f5047b0492f0de517f..fa9ed851d2ae3b793499dc6ca6c457e9d27a8380 100644 --- a/gui/src/components/DatasetPage.js +++ b/gui/src/components/DatasetPage.js @@ -19,7 +19,7 @@ import React, { useContext, useState, useEffect, useMemo } from 'react' import PropTypes from 'prop-types' import { Typography, makeStyles } from '@material-ui/core' import { errorContext } from './errors' -import { useApi } from './apiV1' +import { useApi } from './api' import Search from './search/Search' import { SearchContext } from './search/SearchContext' import { DOI } from './search/results/DatasetList' diff --git a/gui/src/components/DownloadButton.js b/gui/src/components/DownloadButton.js index 867cbd84eca666727d94a4c8a3e5bf0e91ba5897..635e800519d03bf283fe6b3cd7059c63ede6e706 100644 --- a/gui/src/components/DownloadButton.js +++ b/gui/src/components/DownloadButton.js @@ -15,111 +15,103 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' +import React, { useState } from 'react' import PropTypes from 'prop-types' import FileSaver from 'file-saver' -import { withApi } from './api' -import { compose } from 'recompose' -import { withErrors } from './errors' +import { useErrors } from './errors' import { apiBase } from '../config' import { Tooltip, IconButton, Menu, MenuItem } from '@material-ui/core' import DownloadIcon from '@material-ui/icons/CloudDownload' +import { useApi } from './api' -class DownloadButton extends React.Component { - static propTypes = { - /** - * The query that defines what to download. - */ - query: PropTypes.object.isRequired, - /** - * A tooltip for the button - */ - tooltip: PropTypes.string, - /** - * Whether the button is disabled - */ - disabled: PropTypes.bool, - /** - * Properties forwarded to the button. - */ - buttonProps: PropTypes.object, - api: PropTypes.object.isRequired, - user: PropTypes.object, - raiseError: PropTypes.func.isRequired, - dark: PropTypes.bool - } +const DownloadButton = React.memo(function DownloadButton(props) { + const {tooltip, disabled, buttonProps, dark, query} = props + const {api, user} = useApi() + const {raiseError} = useErrors() - state = { - preparingDownload: false, - anchorEl: null - } + const [preparingDownload, setPreparingDownload] = useState(false) + const [anchorEl, setAnchorEl] = useState(null) - handleClick(event) { + const handleClick = event => { event.stopPropagation() - this.setState({ anchorEl: event.currentTarget }) + setAnchorEl(event.currentTarget) } - async handleSelect(choice) { - const {api, query, user, raiseError} = this.props - - const params = {} - Object.keys(query).forEach(key => { params[key] = query[key] }) - - if (user) { - try { - const token = await api.getSignatureToken() - params['signature_token'] = token - } catch (e) { - this.setState({preparingDownload: false}) - raiseError(e) - } - } + const handleSelect = (choice) => { + setAnchorEl(null) - // TODO using split/append is not consistent for all parameters, using append here const urlSearchParams = new URLSearchParams() - Object.keys(params).forEach(key => { - const value = params[key] + Object.keys(query).forEach(key => { + const value = query[key] if (Array.isArray(value)) { value.forEach(item => urlSearchParams.append(key, item)) } else { urlSearchParams.append(key, value) } }) - const url = `${apiBase}/${choice}/${choice === 'archive' ? 'download' : 'query'}?${urlSearchParams.toString()}` - FileSaver.saveAs(url, `nomad-${choice}-download.zip`) - this.setState({preparingDownload: false, anchorEl: null}) - } - - handleClose() { - this.setState({anchorEl: null}) - } - render() { - const {tooltip, disabled, buttonProps, dark} = this.props - const {preparingDownload, anchorEl} = this.state + const openDownload = () => { + const url = `${apiBase}/${choice}/${choice === 'archive' ? 'download' : 'query'}?${urlSearchParams.toString()}` + FileSaver.saveAs(url, `nomad-${choice}-download.zip`) + } - const props = { - ...buttonProps, - disabled: disabled || preparingDownload, - onClick: this.handleClick.bind(this) + if (user) { + setPreparingDownload(true) + api.get('/auth/signature_token') + .then(response => { + urlSearchParams.append('signature_token', response.signature_token) + openDownload() + }) + .catch(raiseError) + .finally(setPreparingDownload(false)) + } else { + openDownload() } + } - return <React.Fragment> - <IconButton {...props} style={dark ? {color: 'white'} : null}> - <Tooltip title={tooltip || 'Download'}> - <DownloadIcon /> - </Tooltip> - </IconButton> - <Menu - anchorEl={anchorEl} - open={Boolean(anchorEl)} - onClose={this.handleClose.bind(this)} - > - <MenuItem onClick={() => this.handleSelect('raw')}>Raw uploaded files</MenuItem> - <MenuItem onClick={() => this.handleSelect('archive')}>NOMAD Archive files</MenuItem> - </Menu> - </React.Fragment> + const handleClose = () => { + setAnchorEl(null) } + + return <React.Fragment> + <IconButton + {...buttonProps} + disabled={disabled || preparingDownload} + onClick={handleClick} + style={dark ? {color: 'white'} : null} + > + <Tooltip title={tooltip || 'Download'}> + <DownloadIcon /> + </Tooltip> + </IconButton> + <Menu + anchorEl={anchorEl} + open={Boolean(anchorEl)} + onClose={handleClose} + > + <MenuItem onClick={() => handleSelect('raw')}>Raw uploaded files</MenuItem> + <MenuItem onClick={() => handleSelect('archive')}>NOMAD Archive files</MenuItem> + </Menu> + </React.Fragment> +}) +DownloadButton.propTypes = { + /** + * The query that defines what to download. + */ + query: PropTypes.object.isRequired, + /** + * A tooltip for the button + */ + tooltip: PropTypes.string, + /** + * Whether the button is disabled + */ + disabled: PropTypes.bool, + /** + * Properties forwarded to the button. + */ + buttonProps: PropTypes.object, + dark: PropTypes.bool } -export default compose(withApi(false), withErrors)(DownloadButton) +export default DownloadButton diff --git a/gui/src/components/EditUserMetadataDialog.js b/gui/src/components/EditUserMetadataDialog.js index 7c3fa95d5f0f2f57042b726e4e47ae57b36b791a..bf07d8394f8eb226a902406b7b8388cc4a49677a 100644 --- a/gui/src/components/EditUserMetadataDialog.js +++ b/gui/src/components/EditUserMetadataDialog.js @@ -25,14 +25,13 @@ import DialogContentText from '@material-ui/core/DialogContentText' import DialogTitle from '@material-ui/core/DialogTitle' import PropTypes from 'prop-types' import { IconButton, Tooltip, withStyles, Paper, MenuItem, Popper, CircularProgress, - FormGroup, Checkbox, FormLabel } from '@material-ui/core' + FormGroup, Checkbox } from '@material-ui/core' import EditIcon from '@material-ui/icons/Edit' import AddIcon from '@material-ui/icons/Add' import RemoveIcon from '@material-ui/icons/Delete' import Autosuggest from 'react-autosuggest' import match from 'autosuggest-highlight/match' import parse from 'autosuggest-highlight/parse' -import { compose } from 'recompose' import { withApi } from './api' const local_users = {} @@ -269,9 +268,9 @@ class UserInputUnstyled extends React.Component { const {api} = this.props query = query.toLowerCase() return api.getUsers(query) - .then(result => { - result.users.forEach(user => update_local_user(user)) - const withQueryInName = result.users.filter( + .then(users => { + users.forEach(user => update_local_user(user)) + const withQueryInName = users.filter( user => user.name.toLowerCase().indexOf(query) !== -1) withQueryInName.sort((a, b) => { const aValue = a.name.toLowerCase() @@ -322,7 +321,7 @@ class UserInputUnstyled extends React.Component { } } -const UserInput = withApi(false)(UserInputUnstyled) +const UserInput = withApi(UserInputUnstyled) class DatasetInputUnstyled extends React.Component { static propTypes = { @@ -373,7 +372,7 @@ class DatasetInputUnstyled extends React.Component { } } -const DatasetInput = withApi(false)(DatasetInputUnstyled) +const DatasetInput = withApi(DatasetInputUnstyled) class ReferenceInput extends React.Component { static propTypes = { @@ -479,7 +478,7 @@ class ListTextInputUnstyled extends React.Component { values: PropTypes.arrayOf(PropTypes.object).isRequired, label: PropTypes.string, onChange: PropTypes.func, - component: PropTypes.func + component: PropTypes.any } static styles = theme => ({ @@ -611,15 +610,10 @@ class InviteUserDialogUnstyled extends React.Component { this.props.api.inviteUser(this.state.data).then(() => { this.handleClose() }).catch(error => { - // get message in quotes - console.error(error) - try { - let message = ('' + error).match(/'([^']+)'/)[1] - try { - message = JSON.parse(message).errorMessage - } catch (e) {} - this.setState({error: message, submitting: false, submitEnabled: false}) - } catch (e) { + const detail = error?.response?.data?.detail + if (detail) { + this.setState({error: detail, submitting: false, submitEnabled: false}) + } else { this.setState({error: '' + error, submitting: false, submitEnabled: false}) } }) @@ -692,7 +686,7 @@ class InviteUserDialogUnstyled extends React.Component { } } -const InviteUserDialog = compose(withApi(true, false), withStyles(InviteUserDialogUnstyled.styles))(InviteUserDialogUnstyled) +const InviteUserDialog = withApi(withStyles(InviteUserDialogUnstyled.styles)(InviteUserDialogUnstyled)) class UserMetadataFieldUnstyled extends React.PureComponent { static propTypes = { @@ -749,7 +743,6 @@ class EditUserMetadataDialogUnstyled extends React.Component { disabled: PropTypes.bool, title: PropTypes.string, info: PropTypes.object, - withoutLiftEmbargo: PropTypes.bool, text: PropTypes.string } @@ -770,9 +763,6 @@ class EditUserMetadataDialogUnstyled extends React.Component { left: '50%', marginTop: -12, marginLeft: -12 - }, - liftEmbargoLabel: { - marginTop: theme.spacing(3) } }) @@ -788,10 +778,7 @@ class EditUserMetadataDialogUnstyled extends React.Component { references: [], coauthors: [], shared_with: [], - datasets: [], - with_embargo: { - value: 'lift' - } + datasets: [] } this.unmounted = false } @@ -1019,7 +1006,7 @@ class EditUserMetadataDialogUnstyled extends React.Component { return ( <React.Fragment> {!this.props.text && - <IconButton {...buttonProps}> + <IconButton {...allButtonProps}> <Tooltip {...tooltipProps}> <EditIcon /> </Tooltip> @@ -1085,9 +1072,6 @@ class EditUserMetadataDialogUnstyled extends React.Component { label="Datasets" /> </UserMetadataField> - {!this.props.withoutLiftEmbargo && <UserMetadataField classes={{container: classes.liftEmbargoLabel}} {...metadataFieldProps('with_embargo', true, 'lift')}> - <FormLabel>Lift embargo</FormLabel> - </UserMetadataField>} </DialogContent> {this.renderDialogActions(submitting, submitEnabled)} </Dialog> @@ -1126,4 +1110,4 @@ class EditUserMetadataDialogUnstyled extends React.Component { } } -export default compose(withApi(false, false), withStyles(EditUserMetadataDialogUnstyled.styles))(EditUserMetadataDialogUnstyled) +export default withApi(withStyles(EditUserMetadataDialogUnstyled.styles)(EditUserMetadataDialogUnstyled)) diff --git a/gui/src/components/LoginLogout.js b/gui/src/components/LoginLogout.js index 5eacd431bb39ccc08b44d2c73848e885162f6a5f..af30e5023c117fcd87a300477af3c0d84f9cbb11 100644 --- a/gui/src/components/LoginLogout.js +++ b/gui/src/components/LoginLogout.js @@ -17,70 +17,66 @@ */ import React from 'react' import PropTypes from 'prop-types' -import { withStyles } from '@material-ui/core/styles' import Typography from '@material-ui/core/Typography' -import { compose } from 'recompose' -import { Button, Link } from '@material-ui/core' -import { withApi } from './api' +import { Button, Link, makeStyles } from '@material-ui/core' import { keycloakBase, keycloakRealm } from '../config' import LoginIcon from '@material-ui/icons/AccountCircle' +import { useApi } from './api' +import { useKeycloak } from 'react-keycloak' -class LoginLogout extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - variant: PropTypes.string, - color: PropTypes.string, - user: PropTypes.object, - keycloak: PropTypes.object.isRequired - } - - static styles = theme => ({ - root: { - display: 'flex', - alignItems: 'center', - '& p': { - marginRight: theme.spacing(2) - } - }, - link: { - textDecoration: 'underline' - }, - button: {} // to allow overrides - }) +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + alignItems: 'center', + '& p': { + marginRight: theme.spacing(2) + } + }, + link: { + textDecoration: 'underline' + }, + button: {} // to allow overrides +})) - render() { - const { classes, variant, color, keycloak, user } = this.props +const LoginLogout = React.memo(function LoginLogout(props) { + const classes = useStyles() + const {user} = useApi() + const {keycloak} = useKeycloak() + const {variant, color} = props - if (keycloak.authenticated) { - return ( - <div className={classes.root}> - <Typography color="primary" variant="body1"> - Welcome <Link - className={classes.link} - href={`${keycloakBase.replace(/\/$/, '')}/realms/${keycloakRealm}/account/`}> - { user ? user.name : '...' } - </Link> - </Typography> - <Button - className={classes.button} style={{marginLeft: 8}} - variant={variant} color={color} - onClick={() => keycloak.logout()} - startIcon={<LoginIcon/>} - >Logout</Button> - </div> - ) - } else { - return ( - <div className={classes.root}> - <Button - className={classes.button} variant={variant} color={color} - startIcon={<LoginIcon/>} - onClick={() => keycloak.login()} - >Login / Register</Button> - </div> - ) - } + if (keycloak.authenticated) { + return ( + <div className={classes.root}> + <Typography color="primary" variant="body1"> + Welcome <Link + className={classes.link} + href={`${keycloakBase.replace(/\/$/, '')}/realms/${keycloakRealm}/account/`}> + { user ? user.name : '...' } + </Link> + </Typography> + <Button + className={classes.button} style={{marginLeft: 8}} + variant={variant} color={color} + onClick={() => keycloak.logout()} + startIcon={<LoginIcon/>} + >Logout</Button> + </div> + ) + } else { + return ( + <div className={classes.root}> + <Button + className={classes.button} variant={variant} color={color} + startIcon={<LoginIcon/>} + onClick={() => keycloak.login()} + >Login / Register</Button> + </div> + ) } +}) +LoginLogout.propTypes = { + variant: PropTypes.string, + color: PropTypes.string } -export default compose(withApi(false), withStyles(LoginLogout.styles))(LoginLogout) +export default LoginLogout diff --git a/gui/src/components/UserdataPage.js b/gui/src/components/UserdataPage.js index 46df92171db8cd5567630337c12d23b5edd9f743..8c09518671db39075676e80b3de7a5963205fddd 100644 --- a/gui/src/components/UserdataPage.js +++ b/gui/src/components/UserdataPage.js @@ -16,7 +16,7 @@ * limitations under the License. */ import React from 'react' -import { withLoginRequired } from './apiV1' +import { withLoginRequired } from './api' import { SearchContext } from './search/SearchContext' import Search from './search/Search' diff --git a/gui/src/components/api.js b/gui/src/components/api.js index d3e9db5d83b03ab0557468da3e5ee76b1dfe5ae8..0a9382f28cc0df91618ffd219535e124758cd215 100644 --- a/gui/src/components/api.js +++ b/gui/src/components/api.js @@ -15,20 +15,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useContext, useEffect, useCallback, useRef } from 'react' +import React, { useEffect, useMemo, useState } from 'react' +import { + atom, + useSetRecoilState, + useRecoilValue, + useRecoilState +} from 'recoil' import PropTypes from 'prop-types' -import { withErrors } from './errors' -import { UploadRequest } from '@navjobs/upload' -import Swagger from 'swagger-client' import { apiBase } from '../config' -import { Typography, withStyles } from '@material-ui/core' +import { makeStyles, Typography } from '@material-ui/core' import LoginLogout from './LoginLogout' -import { compose } from 'recompose' -import { withKeycloak } from 'react-keycloak' +import { useKeycloak } from 'react-keycloak' +import axios from 'axios' +import { useErrors } from './errors' import * as searchQuantities from '../searchQuantities.json' -export const apiContext = React.createContext() - export class DoesNotExist extends Error { constructor(msg) { super(msg) @@ -50,89 +52,6 @@ export class ApiError extends Error { } } -const upload_to_gui_ids = {} -let gui_upload_id_counter = 0 - -class Upload { - constructor(json, api) { - this.api = api - - // Cannot use upload_id as key in GUI, because uploads don't have an upload_id - // before upload is completed - if (json.upload_id) { - // instance from the API - this.gui_upload_id = upload_to_gui_ids[json.upload_id] - if (this.gui_upload_id === undefined) { - // never seen in the GUI, needs a GUI id - this.gui_upload_id = gui_upload_id_counter++ - upload_to_gui_ids[json.upload_id] = this.gui_upload_id - } - } else { - // new instance, not from the API - this.gui_upload_id = gui_upload_id_counter++ - } - Object.assign(this, json) - } - - uploadFile(file) { - const uploadFileWithProgress = async () => { - const authHeaders = await this.api.authHeaders() - let uploadRequest = await UploadRequest( - { - request: { - url: `${apiBase}/uploads/?name=${this.name}`, - method: 'PUT', - headers: { - 'Content-Type': 'application/gzip', - ...authHeaders - } - }, - files: [file], - progress: value => { - this.uploading = value - } - } - ) - if (uploadRequest.error) { - handleApiError(uploadRequest.response ? uploadRequest.response.message : uploadRequest.error) - } - if (uploadRequest.aborted) { - throw Error('User abort') - } - this.uploading = 100 - this.upload_id = uploadRequest.response.upload_id - upload_to_gui_ids[this.upload_id] = this.gui_upload_id - } - - return uploadFileWithProgress() - .then(() => this) - } - - get(page, perPage, orderBy, order) { - if (this.uploading !== null && this.uploading !== 100) { - return new Promise(resolve => resolve(this)) - } else { - if (this.upload_id) { - return this.api.swagger().then(client => client.apis.uploads.get_upload({ - upload_id: this.upload_id, - page: page || 1, - per_page: perPage || 5, - order_by: orderBy || 'mainfile', - order: order || -1 - })) - .catch(handleApiError) - .then(response => response.body) - .then(uploadJson => { - Object.assign(this, uploadJson) - return this - }) - } else { - return new Promise(resolve => resolve(this)) - } - } - } -} - function handleApiError(e) { if (e.name === 'CannotReachApi' || e.name === 'NotAuthorized' || e.name === 'DoesNotExist') { throw e @@ -167,49 +86,49 @@ function handleApiError(e) { } class Api { - swagger() { - if (this.keycloak.token) { - const self = this - return new Promise((resolve, reject) => { - self.keycloak.updateToken() - .success(() => { - self._swaggerClient - .then(swaggerClient => { - swaggerClient.authorizations = { - 'OpenIDConnect Bearer Token': `Bearer ${self.keycloak.token}` - } - resolve(swaggerClient) - }) - .catch(() => { - reject(new ApiError()) - }) - }) - .error(() => { - reject(new ApiError()) - }) - }) - } else { - const self = this - return new Promise((resolve, reject) => { - self._swaggerClient - .then(swaggerClient => { - swaggerClient.authorizations = {} - resolve(swaggerClient) - }) - .catch(() => { - reject(new ApiError()) - }) - }) + constructor(keycloak, setLoading) { + this.keycloak = keycloak + this.setLoading = setLoading + this.axios = axios.create({ + baseURL: `${apiBase}/v1` + }) + + this.nLoading = 0 + + this.onFinishLoading = (show) => { + if (show) { + this.nLoading-- + if (this.nLoading === 0) { + setLoading(false) + } + } } + this.onStartLoading = (show) => { + if (show) { + this.nLoading++ + if (this.nLoading > 0) { + setLoading(true) + } + } + } + + this.subscriptions = {} } - authHeaders() { + /** + * Fetches the up-to-date authorization headers. Performs a token update if + * the current token has expired. + * @returns Object containing the authorization header. + */ + async authHeaders() { if (this.keycloak.token) { return new Promise((resolve, reject) => { this.keycloak.updateToken() .success(() => { resolve({ - 'Authorization': `Bearer ${this.keycloak.token}` + headers: { + 'Authorization': `Bearer ${this.keycloak.token}` + } }) }) .error(() => { @@ -221,254 +140,227 @@ class Api { } } - constructor(keycloak) { - this._swaggerClient = Swagger(`${apiBase}/swagger.json`) - this.keycloak = keycloak - - this.loadingHandler = [] - this.loading = 0 - - this.onFinishLoading = () => { - this.loading-- - this.loadingHandler.forEach(handler => handler(this.loading)) - } - this.onStartLoading = () => { - this.loading++ - this.loadingHandler.forEach(handler => handler(this.loading)) + /** + * Returns the entry information that is stored in the search index. + * + * @param {string} entryId + * @returns Object containing the search index contents. + */ + async entry(entryId) { + this.onStartLoading() + const auth = await this.authHeaders() + try { + const entry = await this.axios.get(`/entries/${entryId}`, auth) + return entry.data + } catch (errors) { + handleApiError(errors) + } finally { + this.onFinishLoading() } - - Api.uploadIds = 0 } - createUpload(name) { - const upload = new Upload({ - upload_id: Api.uploadIds++, - name: name, - current_process_step: 'uploading', - uploading: 0, - create_time: new Date() - }, this) - - return upload + /** + * Returns the data related to the specified dataset. + * + * @param {string} datasetID + * @returns Object containing the search index contents. + */ + async datasets(datasetId) { + this.onStartLoading() + const auth = await this.authHeaders() + try { + const entry = await this.axios.get(`/datasets/${datasetId}`, auth) + return entry.data.data + } catch (errors) { + handleApiError(errors) + } finally { + this.onFinishLoading() + } } - onLoading(handler) { - this.loadingHandler = [...this.loadingHandler, handler] + /** + * Returns section_results from the archive corresponding to the given entry. + * Some large quantities which are not required by the GUI are filtered out. + * All references within the section are resolved by the server before + * sending. + * + * @param {string} entryId + * @returns Object containing section_results + */ + async results(entryId) { + this.onStartLoading() + const auth = await this.authHeaders() + try { + const entry = await this.axios.post( + `/entries/${entryId}/archive/query`, + { + required: { + 'resolve-inplace': false, + results: { + material: '*', + method: '*', + properties: { + structures: '*', + electronic: 'include-resolved', + vibrational: 'include-resolved', + // We require only the energies: trajectory, optimized + // structure, etc. are unnecessary. + geometry_optimization: { + energies: 'include-resolved' + }, + spectra: 'include-resolved' + } + } + } + }, + auth + ) + return parse(entry).data.data.archive + } catch (errors) { + handleApiError(errors) + } finally { + this.onFinishLoading() + } } - removeOnLoading(handler) { - this.loadingHandler = this.loadingHandler.filter(item => item !== handler) + /** + * Returns a list of suggestions for the given metainfo quantities. + * + * @param {string} quantity The quantity names for which suggestions are + * returned. + * @param {string} input Input used to filter the responses. Must be provided + * in order to return suggestions. + * + * @returns List of suggested values. The items are ordered by how well they + * match the input. + */ + async suggestions(quantities, input) { + const auth = await this.authHeaders() + try { + const suggestions = await this.axios.post( + `/suggestions`, + { + input: input, + quantities: quantities + }, + auth + ) + return suggestions.data + } catch (errors) { + handleApiError(errors) + } } - async getUploads(state, page, perPage) { - state = state || 'all' - page = page || 1 - perPage = perPage || 10 - + /** + * Return the raw file metadata for a given entry. + * @param {string} entryId + * @returns Object containing the raw file metadata. + */ + async getRawFileListFromCalc(entryId) { this.onStartLoading() - return this.swagger() - .then(client => client.apis.uploads.get_uploads({state: state, page: page, per_page: perPage})) - .catch(handleApiError) - .then(response => ({ - ...response.body, - results: response.body.results.map(uploadJson => { - const upload = new Upload(uploadJson, this) - upload.uploading = 100 - return upload - }) - })) - .finally(this.onFinishLoading) + const auth = await this.authHeaders() + try { + const entry = await this.axios.get(`/entries/${entryId}/raw`, auth) + return entry.data + } catch (errors) { + handleApiError(errors) + } finally { + this.onFinishLoading() + } } - async getUnpublishedUploads() { - return this.getUploads('unpublished', 1, 1000) + /** + * Executes the given entry query + * @param {object} search contains the search object + * @param {string} searchTarget The target of the search: entries or materials + * @returns Object containing the raw file metadata. + */ + async query(searchTarget, search, show = true) { + this.onStartLoading(show) + const auth = await this.authHeaders() + try { + const result = await this.axios.post( + `${searchTarget}/query`, + { + exclude: ['atoms', 'only_atoms', 'files', 'dft.quantities', 'dft.optimade', 'dft.labels', 'dft.geometries'], + ...search + }, + auth + ) + return result.data + } catch (errors) { + handleApiError(errors) + } finally { + this.onFinishLoading(show) + } } - async getPublishedUploads(page, perPage) { - return this.getUploads('published', 1, 10) + async get(path, query, config) { + const method = (path, body, config) => this.axios.get(path, config) + return this.doHttpRequest(method, path, null, {params: query, ...config}) } - async archive(uploadId, calcId) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.archive.get_archive_calc({ - upload_id: uploadId, - calc_id: calcId - })) - .catch(handleApiError) - .then(response => { - const result = response.body || response.text || response.data - if (typeof result === 'string') { - try { - return JSON.parse(result) - } catch (e) { - try { - return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"')) - } catch (e) { - return result - } - } - } else { - return result - } - }) - .finally(this.onFinishLoading) + async post(path, body, config) { + const method = (path, body, config) => this.axios.post(path, body, config) + return this.doHttpRequest(method, path, body, config) } - async encyclopediaBasic(materialId) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.encyclopedia.get_material({ - material_id: materialId - })) - .catch(handleApiError) - .then(response => { - const result = response.body || response.text || response.data - if (typeof result === 'string') { - try { - return JSON.parse(result) - } catch (e) { - try { - return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"')) - } catch (e) { - return result - } - } - } else { - return result - } - }) - .finally(this.onFinishLoading) + async put(path, body, config) { + const method = (path, body, config) => this.axios.put(path, body, config) + return this.doHttpRequest(method, path, body, config) } - async encyclopediaCalculations(materialId) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.encyclopedia.get_calculations({ - material_id: materialId - })) - .catch(handleApiError) - .then(response => { - const result = response.body || response.text || response.data - if (typeof result === 'string') { - try { - return JSON.parse(result) - } catch (e) { - try { - return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"')) - } catch (e) { - return result - } - } - } else { - return result - } - }) - .finally(this.onFinishLoading) + async delete(path, config) { + const method = (path, body, config) => this.axios.delete(path, config) + return this.doHttpRequest(method, path, null, config) } - async encyclopediaCalculation(materialId, calcId, payload) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.encyclopedia.get_calculation({ - material_id: materialId, - calc_id: calcId, - payload: payload - })) - .catch(handleApiError) - .then(response => { - const result = response.body || response.text || response.data - if (typeof result === 'string') { - try { - return JSON.parse(result) - } catch (e) { - try { - return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"')) - } catch (e) { - return result - } - } - } else { - return result - } - }) - .finally(this.onFinishLoading) - } - - async calcProcLog(uploadId, calcId) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.archive.get_archive_logs({ - upload_id: uploadId, - calc_id: calcId - })) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) + async doHttpRequest(method, path, body, config) { + const noLoading = config?.noLoading + if (!noLoading) { + this.onStartLoading() + } + const auth = await this.authHeaders() + config = config || {} + config.params = config.params || {} + config.headers = config.headers || { + accept: 'application/json', + ...auth.headers + } + try { + const results = await method(path, body, config) + return results.data + } catch (errors) { + if (config.noHandleErrors) { + throw errors + } + handleApiError(errors) + } finally { + if (!noLoading) { + this.onFinishLoading() + } + } } - async getRawFileListFromCalc(uploadId, calcId) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.raw.get_file_list_from_calc({ - upload_id: uploadId, - calc_id: calcId, - path: null - })) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) + async getUsers(prefix) { + // 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) } - async getRawFile(uploadId, calcId, path, kwargs) { - this.onStartLoading() - const length = (kwargs && kwargs.length) || 4096 - return this.swagger() - .then(client => client.apis.raw.get_file_from_calc({ - upload_id: uploadId, - calc_id: calcId, - path: path, - decompress: true, - ...(kwargs || {}), - length: length - })) - .catch(handleApiError) - .then(response => { - /* global Blob */ - /* eslint no-undef: "error" */ - if (response.data instanceof Blob) { - if (response.data.type.endsWith('empty')) { - return { - contents: '', - hasMore: false, - mimeType: 'plain/text' - } - } - return { - contents: null, - hasMore: false, - mimeType: response.data.type - } - } - return { - contents: response.data, - hasMore: response.data.length === length, - mimeType: 'plain/text' - } - }) - .finally(this.onFinishLoading) + async inviteUser(user) { + return this.put('users/invite', user, {noHandleErrors: true}) } - async repo(uploadId, calcId) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.repo.get_repo_calc({ - upload_id: uploadId, - calc_id: calcId - })) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) + async getDatasets(prefix) { + // no loading indicator, because this is only used in the background of the edit dialog + const user = await this.keycloak.loadUserInfo() + const query = { + prefix: prefix, + dataset_type: 'owned', + user_id: user.id, + page_size: 1000 + } + return this.get('datasets', query, {noLoading: true}).then(response => response.data) } async edit(edit) { @@ -484,430 +376,128 @@ class Api { } } }) - return this.swagger() - .then(client => client.apis.repo.edit_repo({payload: edit})) - .catch(handleApiError) - .then(response => response.body) - } - - async resolvePid(pid) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.repo.resolve_pid({pid: pid})) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) - } - - async resolveDoi(doi) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.datasets.resolve_doi({doi: doi})) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) - } - - async search(search) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.repo.search({ - exclude: ['atoms', 'only_atoms', 'files', 'dft.quantities', 'dft.optimade', 'dft.labels', 'dft.geometries'], - ...search})) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) - } - - async getDatasets(prefix) { - // no loading indicator, because this is only used in the background of the edit dialog - return this.swagger() - .then(client => client.apis.datasets.list_datasets({prefix: prefix})) - .catch(handleApiError) - .then(response => response.body) - } - - async assignDatasetDOI(datasetName) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.datasets.assign_doi({name: datasetName})) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) - } - - async deleteDataset(datasetName) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.datasets.delete_dataset({name: datasetName})) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) + return this.post('entries/edit', edit) } +} - async getUsers(query) { - // no loading indicator, because this is only used in the background of the edit dialog - return this.swagger() - .then(client => client.apis.auth.get_users({query: query})) - .catch(handleApiError) - .then(response => response.body) - } - - async inviteUser(user) { - return this.swagger() - .then(client => client.apis.auth.invite_user({payload: user})) - .catch(handleApiError) - .then(response => response.body) - } - - async quantities_search(search) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.repo.quantities_search(search)) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) - } - - async quantity_search(search) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.repo.quantity_search(search)) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) - } - - async suggestions_search(quantity, search, include, size, noLoadingIndicator) { - if (!noLoadingIndicator) { - this.onStartLoading() - } - return this.swagger() - .then(client => client.apis.repo.suggestions_search({ - size: size || 20, - include: include, - quantity: quantity, - ...search - })) - .catch(handleApiError) - .then(response => response.body) - .finally(() => { - if (!noLoadingIndicator) { - this.onFinishLoading() - } - }) - } - - async deleteUpload(uploadId) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.uploads.delete_upload({upload_id: uploadId})) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) - } - - async publishUpload(uploadId, embargoLength) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.uploads.exec_upload_operation({ - upload_id: uploadId, - payload: { - operation: 'publish', - metadata: { - embargo_length: embargoLength - } - } - })) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) - } - - async publishUploadToCentralNomad(uploadId) { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.uploads.exec_upload_operation({ - upload_id: uploadId, - payload: { - operation: 'publish-to-central-nomad' - } - })) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) - } - - async getSignatureToken() { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.auth.get_auth()) - .catch(handleApiError) - .then(response => response.body.signature_token) - .finally(this.onFinishLoading) - } - - _cachedInfo = null - - async getInfo() { - if (!this._cachedInfo) { - this.onStartLoading() - this._cachedInfo = await this.swagger() - .then(client => { - return client.apis.info.get_info() - .then(response => response.body) - .catch(handleApiError) - }) - .finally(this.onFinishLoading) +/** + * Custom JSON parse function that can handle NaNs that can be created by the + * python JSON serializer. + */ +function parse(result) { + if (typeof result === 'string') { + try { + return JSON.parse(result) + } catch (e) { + try { + return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"')) + } catch (e) { + return result + } } - return this._cachedInfo - } - - async getUploadCommand() { - this.onStartLoading() - return this.swagger() - .then(client => client.apis.uploads.get_upload_command()) - .catch(handleApiError) - .then(response => response.body) - .finally(this.onFinishLoading) + } else { + return result } } -export class ApiProviderComponent extends React.Component { - static propTypes = { - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node - ]).isRequired, - raiseError: PropTypes.func.isRequired, - keycloak: PropTypes.object.isRequired, - keycloakInitialized: PropTypes.bool - } - - constructor(props) { - super(props) - this.onToken = this.onToken.bind(this) - } +/** + * Hook that returns a shared instance of the API class and information about + * the currently logged in user. +*/ +export function useApi() { + const [keycloak] = useKeycloak() + const setLoading = useSetLoading() + const [user, setUser] = useState(null) + const [info, setInfo] = useState(null) - onToken(token) { - // console.log(token) - } + const api = useMemo(() => new Api(keycloak, setLoading), [keycloak, setLoading]) - update() { - const { keycloak } = this.props - this.setState({api: this.createApi(keycloak)}) - if (keycloak.token) { - keycloak.loadUserInfo() - .success(user => { - this.setState({user: user}) - }) - .error(error => { - this.props.raiseError(error) - }) + useEffect(() => { + if (keycloak.authenticated) { + keycloak.loadUserInfo().success(setUser) } - } + }, [keycloak, setUser]) - componentDidMount() { - this.update() - } - - componentDidUpdate(prevProps) { - if (this.props.keycloakInitialized !== prevProps.keycloakInitialized) { - this.update() + useEffect(() => { + if (api) { + api.get('/info').then(setInfo).catch(() => {}) } - } - - createApi(keycloak) { - const api = new Api(keycloak) - api.getInfo() - .catch(handleApiError) - .then(info => { - if (info.parsers) { - info.parsers.sort() - } - this.setState({info: info}) - }) - .catch(error => { - this.props.raiseError(error) - }) - - return api - } - - state = { - api: null, - info: null - } + }, [api]) - render() { - const { children } = this.props - return ( - <apiContext.Provider value={this.state}> - {children} - </apiContext.Provider> - ) + return { + api: api, + user: user, + info: info } } -class LoginRequiredUnstyled extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - message: PropTypes.string - } - - static styles = theme => ({ - root: { - display: 'flex', - alignItems: 'center', - padding: theme.spacing(2), - '& p': { - marginRight: theme.spacing(2) - } - } - }) - - render() { - const {classes, message} = this.props - - let loginMessage = '' - if (message) { - loginMessage = <Typography> - {this.props.message} - </Typography> - } - - return ( - <div className={classes.root}> - <div> - {loginMessage} - </div> - <LoginLogout color="primary" /> - </div> - ) - } +/** + * Hooks/state for reading/writing whether the API is loading something. +*/ +const apiLoading = atom({ + key: 'apiLoading', + default: false +}) +export function useLoading() { + return useRecoilValue(apiLoading) } - -export function DisableOnLoading({children}) { - const containerRef = useRef(null) - const {api} = useContext(apiContext) - const handleLoading = useCallback((loading) => { - const enable = loading ? 'none' : '' - containerRef.current.style.pointerEvents = enable - containerRef.current.style.userSelects = enable - }, []) - - useEffect(() => { - api.onLoading(handleLoading) - return () => { - api.removeOnLoading(handleLoading) - } - }, [api, handleLoading]) - - return <div ref={containerRef}>{children}</div> +export function useLoadingState() { + return useRecoilState(apiLoading) } -DisableOnLoading.propTypes = { - children: PropTypes.any.isRequired +export function useSetLoading() { + return useSetRecoilState(apiLoading) } -export const ApiProvider = compose(withKeycloak, withErrors)(ApiProviderComponent) - -const LoginRequired = withStyles(LoginRequiredUnstyled.styles)(LoginRequiredUnstyled) - -const __reauthorize_trigger_changes = ['api', 'calcId', 'uploadId', 'calc_id', 'upload_id'] - -class WithApiComponent extends React.Component { - static propTypes = { - raiseError: PropTypes.func.isRequired, - loginRequired: PropTypes.bool, - showErrorPage: PropTypes.bool, - loginMessage: PropTypes.string, - api: PropTypes.object, - user: PropTypes.object, - Component: PropTypes.any - } - - state = { - notAuthorized: false - } - - constructor(props) { - super(props) - this.raiseError = this.raiseError.bind(this) - } - - componentDidUpdate(prevProps) { - if (__reauthorize_trigger_changes.find(key => this.props[key] !== prevProps[key])) { - this.setState({notAuthorized: false}) +const useLoginRequiredStyles = makeStyles(theme => ({ + root: { + padding: theme.spacing(2), + display: 'flex', + alignItems: 'center', + '& p': { + marginRight: theme.spacing(1) } } +})) - raiseError(error) { - const { raiseError, showErrorPage } = this.props - - console.error(error) - - if (!showErrorPage) { - raiseError(error) - } else { - if (error.name === 'NotAuthorized') { - this.setState({notAuthorized: true}) - } else { - raiseError(error) - } - } - } - - render() { - const { raiseError, loginRequired, loginMessage, Component, ...rest } = this.props - const { api, keycloak } = rest - const { notAuthorized } = this.state - if (notAuthorized) { - if (keycloak.authenticated) { - return ( - <div style={{marginTop: 24}}> - <Typography variant="h6">Not Authorized</Typography> - <Typography> - You are not authorized to access this information. If someone send - you a link to this data, ask the authors to make the data publicly available - or share it with you. - </Typography> - </div> - ) - } else { - return ( - <LoginRequired message="You need to be logged in to access this information." /> - ) - } - } else { - if (api) { - if (keycloak.authenticated || !loginRequired) { - return <Component {...rest} raiseError={this.raiseError} /> - } else { - return <LoginRequired message={loginMessage} /> - } - } else { - return '' - } - } +export function LoginRequired({message, children}) { + const classes = useLoginRequiredStyles() + const {api} = useApi() + if (api.keycloak.authenticated) { + return <React.Fragment> + {children} + </React.Fragment> + } else { + return <div className={classes.root}> + <Typography> + {message || 'You have to login to use this functionality.'} + </Typography> + <LoginLogout color="primary" /> + </div> } } +LoginRequired.propTypes = { + message: PropTypes.string, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +} -const WithKeycloakWithApiCompnent = withKeycloak(WithApiComponent) - -export function withApi(loginRequired, showErrorPage, loginMessage) { - return function(Component) { - return withErrors(props => ( - <apiContext.Consumer> - {apiContext => ( - <WithKeycloakWithApiCompnent - loginRequired={loginRequired} - loginMessage={loginMessage} - showErrorPage={showErrorPage} - Component={Component} - {...props} {...apiContext} - /> - )} - </apiContext.Consumer> - )) - } +/** + * HOC for wrapping components that require a login. Without login will return + * the given message together with a login link. + * + * @param {*} Component + * @param {*} message The message to display + */ +export function withLoginRequired(Component, message) { + return ({...props}) => <LoginRequired message={message}> + <Component {...props} /> + </LoginRequired> } + +export const withApi = (Component) => React.forwardRef((props, ref) => { + const apiProps = useApi() + const {raiseError} = useErrors() + return <Component ref={ref} {...apiProps} raiseError={raiseError} {...props} /> +}) diff --git a/gui/src/components/apiV1.js b/gui/src/components/apiV1.js deleted file mode 100644 index a100cec0e17dd07f70672fc8a6b9d46fa222f945..0000000000000000000000000000000000000000 --- a/gui/src/components/apiV1.js +++ /dev/null @@ -1,444 +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, { useMemo } from 'react' -import { - atom, - useSetRecoilState, - useRecoilValue, - useRecoilState -} from 'recoil' -import PropTypes from 'prop-types' -import { apiBase } from '../config' -import { makeStyles, Typography } from '@material-ui/core' -import LoginLogout from './LoginLogout' -import { useKeycloak } from 'react-keycloak' -import axios from 'axios' - -export class DoesNotExist extends Error { - constructor(msg) { - super(msg) - this.name = 'DoesNotExist' - } -} - -export class NotAuthorized extends Error { - constructor(msg) { - super(msg) - this.name = 'NotAuthorized' - } -} - -export class ApiError extends Error { - constructor(msg) { - super(msg) - this.name = 'CannotReachApi' - } -} - -function handleApiError(e) { - if (e.name === 'CannotReachApi' || e.name === 'NotAuthorized' || e.name === 'DoesNotExist') { - throw e - } - - let error = null - if (e.response) { - const body = e.response.body - const message = (body && (body.message || body.description)) || e.response.statusText - const errorMessage = `${message} (${e.response.status})` - if (e.response.status === 404) { - error = new DoesNotExist(errorMessage) - } else if (e.response.status === 401) { - error = new NotAuthorized(errorMessage) - } else if (e.response.status === 502) { - error = new ApiError(errorMessage) - } else { - error = new Error(errorMessage) - } - error.status = e.response.status - error.apiMessage = message - } else { - if (e.message === 'Failed to fetch') { - error = new ApiError(e.message) - error.status = 400 - } else { - const errorMessage = e.status ? `${e} (${e.status})` : '' + e - error = new Error(errorMessage) - } - } - throw error -} - -class Api { - constructor(keycloak, setLoading) { - this.keycloak = keycloak - this.setLoading = setLoading - this.axios = axios.create({ - baseURL: `${apiBase}/v1` - }) - - this.nLoading = 0 - - this.onFinishLoading = (show) => { - if (show) { - this.nLoading-- - if (this.nLoading === 0) { - setLoading(false) - } - } - } - this.onStartLoading = (show) => { - if (show) { - this.nLoading++ - if (this.nLoading > 0) { - setLoading(true) - } - } - } - - this.subscriptions = {} - } - - /** - * Fetches the up-to-date authorization headers. Performs a token update if - * the current token has expired. - * @returns Object containing the authorization header. - */ - async authHeaders() { - if (this.keycloak.token) { - return new Promise((resolve, reject) => { - this.keycloak.updateToken() - .success(() => { - resolve({ - headers: { - 'Authorization': `Bearer ${this.keycloak.token}` - } - }) - }) - .error(() => { - reject(new ApiError()) - }) - }) - } else { - return {} - } - } - - /** - * Returns the entry information that is stored in the search index. - * - * @param {string} entryId - * @returns Object containing the search index contents. - */ - async entry(entryId) { - this.onStartLoading() - const auth = await this.authHeaders() - try { - const entry = await this.axios.get(`/entries/${entryId}`, auth) - return entry.data - } catch (errors) { - handleApiError(errors) - } finally { - this.onFinishLoading() - } - } - - /** - * Returns the data related to the specified dataset. - * - * @param {string} datasetID - * @returns Object containing the search index contents. - */ - async datasets(datasetId) { - this.onStartLoading() - const auth = await this.authHeaders() - try { - const entry = await this.axios.get(`/datasets/${datasetId}`, auth) - return entry.data.data - } catch (errors) { - handleApiError(errors) - } finally { - this.onFinishLoading() - } - } - - /** - * Returns section_results from the archive corresponding to the given entry. - * Some large quantities which are not required by the GUI are filtered out. - * All references within the section are resolved by the server before - * sending. - * - * @param {string} entryId - * @returns Object containing section_results - */ - async results(entryId) { - this.onStartLoading() - const auth = await this.authHeaders() - try { - const entry = await this.axios.post( - `/entries/${entryId}/archive/query`, - { - required: { - 'resolve-inplace': false, - results: { - material: '*', - method: '*', - properties: { - structures: '*', - electronic: 'include-resolved', - vibrational: 'include-resolved', - // We require only the energies: trajectory, optimized - // structure, etc. are unnecessary. - geometry_optimization: { - energies: 'include-resolved' - }, - spectra: 'include-resolved' - } - } - } - }, - auth - ) - return parse(entry).data.data.archive - } catch (errors) { - handleApiError(errors) - } finally { - this.onFinishLoading() - } - } - - /** - * Returns a list of suggestions for the given metainfo quantities. - * - * @param {string} quantity The quantity names for which suggestions are - * returned. - * @param {string} input Input used to filter the responses. Must be provided - * in order to return suggestions. - * - * @returns List of suggested values. The items are ordered by how well they - * match the input. - */ - async suggestions(quantities, input) { - const auth = await this.authHeaders() - try { - const suggestions = await this.axios.post( - `/suggestions`, - { - input: input, - quantities: quantities - }, - auth - ) - return suggestions.data - } catch (errors) { - handleApiError(errors) - } - } - - /** - * Return the raw file metadata for a given entry. - * @param {string} entryId - * @returns Object containing the raw file metadata. - */ - async getRawFileListFromCalc(entryId) { - this.onStartLoading() - const auth = await this.authHeaders() - try { - const entry = await this.axios.get(`/entries/${entryId}/raw`, auth) - return entry.data - } catch (errors) { - handleApiError(errors) - } finally { - this.onFinishLoading() - } - } - - /** - * Executes the given entry query - * @param {object} search contains the search object - * @param {string} searchTarget The target of the search: entries or materials - * @returns Object containing the raw file metadata. - */ - async query(searchTarget, search, show = true) { - this.onStartLoading(show) - const auth = await this.authHeaders() - try { - const result = await this.axios.post( - `${searchTarget}/query`, - { - exclude: ['atoms', 'only_atoms', 'files', 'dft.quantities', 'dft.optimade', 'dft.labels', 'dft.geometries'], - ...search - }, - auth - ) - return result.data - } catch (errors) { - handleApiError(errors) - } finally { - this.onFinishLoading(show) - } - } - - async get(path, query) { - const method = (path, body, config) => this.axios.get(path, config) - return this.doHttpRequest(method, path, null, {params: query}) - } - - async post(path, body, config) { - const method = (path, body, config) => this.axios.post(path, body, config) - return this.doHttpRequest(method, path, body, config) - } - - async put(path, body, config) { - const method = (path, body, config) => this.axios.put(path, body, config) - return this.doHttpRequest(method, path, body, config) - } - - async delete(path, config) { - const method = (path, body, config) => this.axios.delete(path, config) - return this.doHttpRequest(method, path, null, config) - } - - async doHttpRequest(method, path, body, config) { - this.onStartLoading() - const auth = await this.authHeaders() - config = config || {} - config.params = config.params || {} - config.headers = config.headers || { - accept: 'application/json', - ...auth.headers - } - try { - const results = await method(path, body, config) - return results.data - } catch (errors) { - handleApiError(errors) - } finally { - this.onFinishLoading() - } - } -} - -/** - * Custom JSON parse function that can handle NaNs that can be created by the - * python JSON serializer. - */ -function parse(result) { - if (typeof result === 'string') { - try { - return JSON.parse(result) - } catch (e) { - try { - return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"')) - } catch (e) { - return result - } - } - } else { - return result - } -} - -/** - * Hook that returns a shared instance of the API class and information about - * the currently logged in user. -*/ -let api = null -let user = null -export function useApi() { - const [keycloak] = useKeycloak() - const setLoading = useSetLoading() - - if (!api || api.keycloak !== keycloak) { - api = new Api(keycloak, setLoading) - if (keycloak.authenticated) { - keycloak.loadUserInfo() - .success(data => { - user = data - }) - } - } - const result = useMemo(() => { - return {api, user} - }, []) - return result -} - -/** - * Hooks/state for reading/writing whether the API is loading something. -*/ -const apiLoading = atom({ - key: 'apiLoading', - default: false -}) -export function useLoading() { - return useRecoilValue(apiLoading) -} -export function useLoadingState() { - return useRecoilState(apiLoading) -} -export function useSetLoading() { - return useSetRecoilState(apiLoading) -} - -const useLoginRequiredStyles = makeStyles(theme => ({ - root: { - padding: theme.spacing(2), - display: 'flex', - alignItems: 'center', - '& p': { - marginRight: theme.spacing(1) - } - } -})) - -export function LoginRequired({message, children}) { - const classes = useLoginRequiredStyles() - const {api} = useApi() - if (api.keycloak.authenticated) { - return <React.Fragment> - {children} - </React.Fragment> - } else { - return <div className={classes.root}> - <Typography> - {message || 'You have to login to use this functionality.'} - </Typography> - <LoginLogout color="primary" /> - </div> - } -} -LoginRequired.propTypes = { - message: PropTypes.string, - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node - ]).isRequired -} - -/** - * HOC for wrapping components that require a login. Without login will return - * the given message together with a login link. - * - * @param {*} Component - * @param {*} message The message to display - */ -export function withLoginRequired(Component, message) { - return ({...props}) => <LoginRequired message={message}> - <Component {...props} /> - </LoginRequired> -} diff --git a/gui/src/components/archive/MetainfoBrowser.js b/gui/src/components/archive/MetainfoBrowser.js index d27b23e2806afea256299eab172669facf64c997..f32b908173ccc25e52290a338915f0b65d6ac489 100644 --- a/gui/src/components/archive/MetainfoBrowser.js +++ b/gui/src/components/archive/MetainfoBrowser.js @@ -23,7 +23,6 @@ import Browser, { Item, Content, Compartment, Adaptor, laneContext, formatSubSec import { Typography, Box, makeStyles, Grid, FormGroup, TextField, Button } from '@material-ui/core' import { metainfoDef, resolveRef, vicinityGraph, rootSections, path as metainfoPath, packagePrefixes, defsByName, path } from './metainfo' import * as d3 from 'd3' -import { apiContext } from '../api' import blue from '@material-ui/core/colors/blue' import teal from '@material-ui/core/colors/teal' import lime from '@material-ui/core/colors/lime' @@ -35,6 +34,8 @@ import Histogram from '../Histogram' import { appBase } from '../../config' import { useHistory, useRouteMatch } from 'react-router-dom' import Autocomplete from '@material-ui/lab/Autocomplete' +import { useApi } from '../api' +import { useErrors } from '../errors' export const help = ` The NOMAD *metainfo* defines all quantities used to represent archive data in @@ -429,23 +430,45 @@ Definition.propTypes = { } function DefinitionDetails({def, ...props}) { - const {api} = useContext(apiContext) + const {api} = useApi() + const {raiseError} = useErrors() const lane = useContext(laneContext) const isLast = !lane.next const [usage, setUsage] = useState(null) const [showUsage, setShowUsage] = useState(false) + const quantityPath = useMemo(() => { + const path = lane.path.split('/') + const index = path.indexOf('EntryArchive') + if (index >= 0) { + return path.slice(index + 1).join('.') + } + return null + }, [lane]) + useEffect(() => { if (showUsage) { - api.quantity_search({ - 'dft.quantities': [def.name], - size: 100, // make sure we get all codes - quantity: 'dft.code_name' - }).then(result => { - setUsage(result.quantity.values) - }) + api.post('/entries/query', { + owner: 'visible', + query: { + 'quantities:any': [quantityPath] + }, + aggregations: { + program_names: { + terms: { + quantity: 'results.method.simulation.program_name', + pagination: { + page_size: 100 // make sure we get all codes + } + } + } + } + }).then(response => { + const aggData = response.aggregations.program_names.terms.data + setUsage(aggData) + }).catch(raiseError) } - }, [api, def.name, showUsage, setUsage]) + }, [api, raiseError, showUsage, quantityPath, setUsage]) return <React.Fragment> {def.categories && def.categories.length > 0 && <Compartment title="Categories"> @@ -461,22 +484,22 @@ function DefinitionDetails({def, ...props}) { <VicinityGraph def={def} /> </Compartment> } - {isLast && def.m_def !== 'Category' && def.name !== 'EntryArchive' && !def.extends_base_section && + {quantityPath && <Compartment title="usage"> {!showUsage && <Button fullWidth variant="outlined" onClick={() => setShowUsage(true)}>Show usage</Button>} {showUsage && !usage && <Typography><i>loading ...</i></Typography>} - {usage && Object.keys(usage).length > 0 && ( + {usage && usage.length > 0 && ( <Histogram - data={Object.keys(usage).map(key => ({ - key: key, - name: key, - value: usage[key].total + data={usage.map(use => ({ + key: use.value, + name: use.value, + value: use.count }))} initialScale={0.5} title="Metadata use per code" /> )} - {usage && Object.keys(usage).length === 0 && ( + {usage && usage.length === 0 && ( <Typography color="error"><i>This metadata is not used at all.</i></Typography> )} </Compartment> diff --git a/gui/src/components/dataset/ResolveDOI.js b/gui/src/components/dataset/ResolveDOI.js index 98bf72108c6858ea7f329dd2327f91aff364d828..3e5b793cef1a10f5d8bee6eed90331817fb3ad56 100644 --- a/gui/src/components/dataset/ResolveDOI.js +++ b/gui/src/components/dataset/ResolveDOI.js @@ -15,58 +15,54 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' -import PropTypes from 'prop-types' -import { withStyles, Typography } from '@material-ui/core' -import { compose } from 'recompose' -import { withApi } from '../api' -import { matchPath } from 'react-router' +import React, { useEffect, useState } from 'react' +import { Typography, makeStyles } from '@material-ui/core' +import { matchPath, useLocation, useRouteMatch, useHistory } from 'react-router' +import {useApi} from '../api' +import {useErrors} from '../errors' +import { getUrl } from '../nav/Routes' -class ResolveDOI extends React.Component { - static styles = theme => ({ - root: { - padding: theme.spacing(3) - } - }) - - static propTypes = { - classes: PropTypes.object.isRequired, - api: PropTypes.object.isRequired, - raiseError: PropTypes.func.isRequired, - location: PropTypes.object.isRequired, - match: PropTypes.object.isRequired, - history: PropTypes.object.isRequired +const useStyles = makeStyles(theme => ({ + root: { + padding: theme.spacing(3) } +})) + +export default function ResolveDOI() { + const classes = useStyles() + const {api} = useApi() + const {raiseError} = useErrors() + const history = useHistory() + const location = useLocation() + const match = useRouteMatch() - update() { - const { location, match, api, history, raiseError } = this.props + const [doesNotExist, setDoesNotExist] = useState(false) + + useEffect(() => { const doiMatch = matchPath(location.pathname, { path: `${match.path}/:doi*` }) let { doi } = doiMatch.params - api.resolveDoi(doi).then(dataset => { - history.push(`/dataset/id/${dataset.dataset_id}`) - }).catch(raiseError) - } + api.get('/datasets', {doi: doi}) + .then(response => { + if (response.pagination.total >= 1) { + const dataset_id = response.data[0].dataset_id + history.push(getUrl(`dataset/id/${dataset_id}`, location)) + } else { + setDoesNotExist(true) + } + }) + .catch(raiseError) + }, [setDoesNotExist, history, location, match, api, raiseError]) - componentDidMount() { - this.update() - } + let message = 'loading ...' - componentDidUpdate(prevProps) { - if (prevProps.location.pathname !== this.props.location.pathname || prevProps.api !== this.props.api) { - this.update() - } + if (doesNotExist) { + message = 'This URL points to a dataset that does not exist.' } - render() { - const { classes } = this.props - - return ( - <Typography className={classes.root}>loading ...</Typography> - ) - } + return ( + <Typography className={classes.root}>{message}</Typography> + ) } - -export default compose(withApi(false), withStyles(ResolveDOI.styles))(ResolveDOI) diff --git a/gui/src/components/entry/ArchiveEntryView.js b/gui/src/components/entry/ArchiveEntryView.js index b72367213f428c997d44d01eca0b83213195cfba..413ec9b63a9389fdefaabff83eb5a2e8803fb5c7 100644 --- a/gui/src/components/entry/ArchiveEntryView.js +++ b/gui/src/components/entry/ArchiveEntryView.js @@ -15,15 +15,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' +import React, { useEffect, useState } from 'react' import PropTypes from 'prop-types' -import { withStyles, Fab, Card, CardContent, Typography } from '@material-ui/core' -import { compose } from 'recompose' -import { withApi } from '../api' +import { Fab, Card, CardContent, Typography, makeStyles } from '@material-ui/core' import DownloadIcon from '@material-ui/icons/CloudDownload' import Download from './Download' import ArchiveBrowser from '../archive/ArchiveBrowser' import Page from '../Page' +import { useErrors } from '../errors' +import { useApi } from '../api' export const help = ` The NOMAD **archive** provides data and meta-data in a common hierarchical format based on @@ -35,129 +35,87 @@ you can click section names to get more information. Browse the *metainfo* to learn more about NOMAD's archive format [here](/metainfo). ` -class ArchiveEntryView extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - api: PropTypes.object.isRequired, - info: PropTypes.object, - raiseError: PropTypes.func.isRequired, - uploadId: PropTypes.string.isRequired, - entryId: PropTypes.string.isRequired +const useStyles = makeStyles(theme => ({ + archiveBrowser: { + marginTop: theme.spacing(2) + }, + error: { + marginTop: theme.spacing(2) + }, + downloadFab: { + zIndex: 1, + right: 32, + bottom: 32, + position: 'fixed !important' } - - static styles = theme => ({ - archiveBrowser: { - marginTop: theme.spacing(2) - }, - error: { - marginTop: theme.spacing(2) - }, - downloadFab: { - zIndex: 1, - right: 32, - bottom: 32, - position: 'fixed !important' - } - }) - - static defaultState = { - data: null, - doesNotExist: false - } - - state = { - ...ArchiveEntryView.defaultState - } - - constructor(props) { - super(props) - this.unmounted = false - } - - componentWillUnmount() { - this.unmounted = true - } - - componentDidMount() { - this.updateArchive() - } - - componentDidUpdate(prevProps) { - if (prevProps.api !== this.props.api || - prevProps.uploadId !== this.props.uploadId || - prevProps.entryId !== this.props.entryId) { - this.setState({...ArchiveEntryView.defaultState}) - this.updateArchive() - } - } - - updateArchive() { - const {uploadId, entryId, api} = this.props - api.archive(uploadId, entryId).then(data => { - if (!this.unmounted) { - this.setState({data: data}) - } - }).catch(error => { - if (!this.unmounted) { - this.setState({data: null}) - } - if (error.name === 'DoesNotExist') { - this.setState({doesNotExist: true}) - } else { - this.props.raiseError(error) - } - }) - } - - render() { - const { classes, uploadId, entryId } = this.props - const { data, doesNotExist } = this.state - - if (doesNotExist) { - return ( - <Page> - <Typography className={classes.error}> - No archive exists for this entry. Either the archive was not generated due - to parsing or other processing errors (check the log tab), or the entry it - self does not exist. - </Typography> - </Page> - ) - } - - return ( - <Page width={'100%'} maxWidth={'undefined'}> - { - data && typeof data !== 'string' - ? <div className={classes.archiveBrowser}> - <ArchiveBrowser data={data} /> - </div> : <div>{ - data - ? <div> - <Typography>Archive data is not valid JSON. Displaying plain text instead.</Typography> - <Card> - <CardContent> - <pre>{data || ''}</pre> - </CardContent> - </Card> - </div> - : <Typography>loading ...</Typography> - }</div> +})) + +export default function ArchiveEntryView(props) { + const classes = useStyles() + const {entryId} = props + const {api} = useApi() + const {raiseError} = useErrors() + + const [data, setData] = useState(null) + const [doesNotExist, setDoesNotExist] = useState(false) + + useEffect(() => { + api.get(`/entries/${entryId}/archive`) + .then(response => { + response.data.archive.processing_logs = undefined + setData(response.data.archive) + }) + .catch(error => { + if (error.name === 'DoesNotExist') { + setDoesNotExist(true) + } else { + raiseError(error) } + }) + }, [setData, setDoesNotExist, api, raiseError, entryId]) - <Download - classes={{root: classes.downloadFab}} tooltip="download calculation archive" - component={Fab} className={classes.downloadFab} color="primary" size="medium" - url={`archive/${uploadId}/${entryId}`} fileName={`${entryId}.json`} - > - <DownloadIcon /> - </Download> + if (doesNotExist) { + return ( + <Page> + <Typography className={classes.error}> + No archive exists for this entry. Either the archive was not generated due + to parsing or other processing errors (check the log tab), or the entry it + self does not exist. + </Typography> </Page> ) } -} -export default compose( - withApi(false, true), - withStyles(ArchiveEntryView.styles) -)(ArchiveEntryView) + return ( + <Page width={'100%'} maxWidth={'undefined'}> + { + data && typeof data !== 'string' + ? <div className={classes.archiveBrowser}> + <ArchiveBrowser data={data} /> + </div> : <div>{ + data + ? <div> + <Typography>Archive data is not valid JSON. Displaying plain text instead.</Typography> + <Card> + <CardContent> + <pre>{data || ''}</pre> + </CardContent> + </Card> + </div> + : <Typography>loading ...</Typography> + }</div> + } + + <Download + classes={{root: classes.downloadFab}} tooltip="download calculation archive" + component={Fab} className={classes.downloadFab} color="primary" size="medium" + url={`entries/${entryId}/archive/download`} fileName={`${entryId}.json`} + > + <DownloadIcon /> + </Download> + </Page> + ) +} +ArchiveEntryView.propTypes = { + entryId: PropTypes.string.isRequired +} diff --git a/gui/src/components/entry/ArchiveLogView.js b/gui/src/components/entry/ArchiveLogView.js index 4f53367a37db4b26de56b5a1d949af1fea5282fa..ade07eeb85a535f9a41bfd6a53540d29bd13aebd 100644 --- a/gui/src/components/entry/ArchiveLogView.js +++ b/gui/src/components/entry/ArchiveLogView.js @@ -15,162 +15,122 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' +import React, { useEffect, useState } from 'react' import PropTypes from 'prop-types' -import { withStyles, Fab, Typography, Accordion, AccordionSummary, AccordionDetails } from '@material-ui/core' -import { compose } from 'recompose' -import { withApi } from '../api' -import Download from './Download' -import DownloadIcon from '@material-ui/icons/CloudDownload' +import { Typography, Accordion, AccordionSummary, AccordionDetails, makeStyles } from '@material-ui/core' import ExpandMoreIcon from '@material-ui/icons/ExpandMore' import ReactJson from 'react-json-view' import { amber } from '@material-ui/core/colors' import { maxLogsToShow } from '../../config' import Page from '../Page' - -class LogEntryUnstyled extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - entry: PropTypes.object.isRequired +import { useErrors } from '../errors' +import { useApi } from '../api' + +const useLogEntryStyles = makeStyles(theme => ({ + warning: { + color: amber[700] + }, + exception: { + overflowX: 'scroll', + margin: 0 } - - static styles = theme => ({ - warning: { - color: amber[700] - }, - exception: { - overflowX: 'scroll', - margin: 0 - } - }) - - render() { - const { classes, entry } = this.props - const data = entry - - const summaryProps = {} - if (data.level === 'ERROR' || data.level === 'CRITICAL') { - summaryProps.color = 'error' - } else if (data.level === 'WARNING') { - summaryProps.classes = {root: classes.warning} - } - return ( - <Accordion> - <AccordionSummary expandIcon={<ExpandMoreIcon />}> - <Typography {...summaryProps}>{data.level}: {data.event} {(data.parser || data.normalizer) ? `(${data.parser || data.normalizer})` : ''}</Typography> - </AccordionSummary> - <AccordionDetails> - <ReactJson - src={data} - enableClipboard={false} - displayObjectSize={false} /> - </AccordionDetails> - {data.exception && <AccordionDetails> - <pre className={classes.exception}>{data.exception}</pre> - </AccordionDetails>} - </Accordion>) +})) + +const LogEntry = React.memo(function LogEntry(props) { + const classes = useLogEntryStyles() + const {entry} = props + const data = entry + + const summaryProps = {} + if (data.level === 'ERROR' || data.level === 'CRITICAL') { + summaryProps.color = 'error' + } else if (data.level === 'WARNING') { + summaryProps.classes = {root: classes.warning} } + return ( + <Accordion> + <AccordionSummary expandIcon={<ExpandMoreIcon />}> + <Typography {...summaryProps}>{data.level}: {data.event} {(data.parser || data.normalizer) ? `(${data.parser || data.normalizer})` : ''}</Typography> + </AccordionSummary> + <AccordionDetails> + <ReactJson + src={data} + enableClipboard={false} + displayObjectSize={false} /> + </AccordionDetails> + {data.exception && <AccordionDetails> + <pre className={classes.exception}>{data.exception}</pre> + </AccordionDetails>} + </Accordion> + ) +}) +LogEntry.propTypes = { + entry: PropTypes.object.isRequired } -const LogEntry = withStyles(LogEntryUnstyled.styles)(LogEntryUnstyled) - -class ArchiveLogView extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - api: PropTypes.object.isRequired, - raiseError: PropTypes.func.isRequired, - uploadId: PropTypes.string.isRequired, - entryId: PropTypes.string.isRequired +const useStyles = makeStyles(theme => ({ + moreLogs: { + marginTop: theme.spacing(2) + }, + downloadFab: { + zIndex: 1, + right: 32, + bottom: 32, + position: 'fixed !important' } - - static styles = theme => ({ - moreLogs: { - marginTop: theme.spacing(2) - }, - downloadFab: { - zIndex: 1, - right: 32, - bottom: 32, - position: 'fixed !important' - } - }); - - static defaultState = { - data: null, - doesNotExist: false - } - - state = {...ArchiveLogView.defaultState} - - componentDidMount() { - this.update() - } - - componentDidUpdate(prevProps) { - if (prevProps.api !== this.props.api || - prevProps.uploadId !== this.props.uploadId || - prevProps.entryId !== this.props.entryId) { - this.setState({...ArchiveLogView.defaultState}) - this.update() - } - } - - update() { - const {uploadId, entryId, api, raiseError} = this.props - api.calcProcLog(uploadId, entryId).then(data => { - this.setState({data: data}) - }).catch(error => { - this.setState({data: null}) - if (error.name === 'DoesNotExist') { - this.setState({doesNotExist: true}) - } else { - raiseError(error) - } - }) - } - - render() { - const { classes, uploadId, entryId } = this.props - const { data, doesNotExist } = this.state - - if (doesNotExist) { - return ( - <Page> - <Typography> - No archive log does exist for this entry. Most likely the entry itself does not - exist. - </Typography> - </Page> - ) - } - - let content = 'loading ...' - if (data) { - content = <div> - {data.slice(0, maxLogsToShow).map((entry, i) => <LogEntry key={i} entry={entry}/>)} - {data.length > maxLogsToShow && <Typography classes={{root: classes.moreLogs}}> - There are {data.length - maxLogsToShow} more log entries. Download the log to see all of them. - </Typography>} - </div> - } - +})) + +export default function ArchiveLogView(props) { + const classes = useStyles() + const {entryId} = props + const {api} = useApi() + const {raiseError} = useErrors() + + const [data, setData] = useState(null) + const [doesNotExist, setDoesNotExist] = useState(false) + + useEffect(() => { + api.post(`/entries/${entryId}/archive/query`, {required: {processing_logs: '*'}}) + .then(response => { + const data = response.data.archive.processing_logs || [] + setData(data) + }) + .catch(error => { + if (error.name === 'DoesNotExist') { + setDoesNotExist(true) + } else { + raiseError(error) + } + }) + }, [setData, setDoesNotExist, api, raiseError, entryId]) + + if (doesNotExist) { return ( - <Page limitedWidth> - {content} - <Download - classes={{root: classes.downloadFab}} tooltip="download logfile" - component={Fab} className={classes.downloadFab} size="medium" - color="primary" - url={`archive/logs/${uploadId}/${entryId}`} fileName={`${entryId}.log`} - > - <DownloadIcon /> - </Download> + <Page> + <Typography> + No archive log does exist for this entry. Most likely the entry itself does not + exist. + </Typography> </Page> ) } -} -export default compose( - withApi(false, true), - withStyles(ArchiveLogView.styles) -)(ArchiveLogView) + let content = 'loading ...' + if (data) { + content = <div> + {data.slice(0, maxLogsToShow).map((entry, i) => <LogEntry key={i} entry={entry}/>)} + {data.length > maxLogsToShow && <Typography classes={{root: classes.moreLogs}}> + There are {data.length - maxLogsToShow} more log entries. Download the log to see all of them. + </Typography>} + </div> + } + + return ( + <Page limitedWidth> + {content} + </Page> + ) +} +ArchiveLogView.propTypes = { + entryId: PropTypes.string.isRequired +} diff --git a/gui/src/components/entry/Download.js b/gui/src/components/entry/Download.js index fd8f2eebc1588aaf6465202fef13d0c2e798fc04..055ae73ce64710d176f2923653b4a0a373bed37a 100644 --- a/gui/src/components/entry/Download.js +++ b/gui/src/components/entry/Download.js @@ -15,88 +15,77 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' +import React, { useState } from 'react' import PropTypes from 'prop-types' import FileSaver from 'file-saver' -import { withApi } from '../api' -import { compose } from 'recompose' -import { withErrors } from '../errors' +import { useErrors } from '../errors' import { apiBase } from '../../config' -import { withStyles, Tooltip } from '@material-ui/core' +import { makeStyles, Tooltip } from '@material-ui/core' +import { useApi } from '../api' -class Download extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - fileName: PropTypes.string, - url: PropTypes.string, - component: PropTypes.any, - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node - ]).isRequired, - api: PropTypes.object.isRequired, - user: PropTypes.object, - disabled: PropTypes.bool, - raiseError: PropTypes.func.isRequired, - tooltip: PropTypes.string, - color: PropTypes.string, - size: PropTypes.string - } +const useStyles = makeStyles(theme => ({ + root: {} +})) - static styles = theme => ({ - root: {} - }) +const Download = React.memo(function Download(props) { + const classes = useStyles(props) + const {url, fileName, component, children, disabled, color, size, tooltip} = props + const {api, user} = useApi() + const {raiseError} = useErrors() - state = { - preparingDownload: false - } + const [preparingDownload, setPreparingDownload] = useState(false) - async onDownloadClicked() { - const {url, api, user, fileName, raiseError} = this.props - let fullUrl = `${apiBase}/${url}` + const handleClick = () => { + let fullUrl = `${apiBase}/v1/${url}` let downloadUrl = fullUrl if (user) { - api.getSignatureToken() - .catch(error => { - this.setState({preparingDownload: false}) - raiseError(error) - }) - .then(result => { + setPreparingDownload(true) + api.get('/auth/signature_token') + .then(response => { if (fullUrl.startsWith('/')) { fullUrl = `${window.location.origin}${fullUrl}` } const downloadUrl = new URL(fullUrl) - downloadUrl.searchParams.append('signature_token', result) + downloadUrl.searchParams.append('signature_token', response.signature_token) FileSaver.saveAs(downloadUrl.href, fileName) - this.setState({preparingDownload: false}) }) + .catch(raiseError) + .finally(() => setPreparingDownload(false)) } else { FileSaver.saveAs(downloadUrl, fileName) - this.setState({preparingDownload: false}) } } - render() { - const {classes, component, children, disabled, color, size, tooltip} = this.props - const {preparingDownload} = this.state + const Component = component - const Component = component + const button = ( + <Component + className={classes.root} + disabled={disabled || preparingDownload} color={color} size={size} + onClick={handleClick} + > + {children} + </Component> + ) - const button = ( - <Component className={classes.root} - disabled={disabled || preparingDownload} color={color} size={size} - onClick={() => this.onDownloadClicked()} - > - {children} - </Component> - ) - - if (tooltip && !disabled && !preparingDownload) { - return <Tooltip title={tooltip}>{button}</Tooltip> - } else { - return button - } + if (tooltip && !disabled && !preparingDownload) { + return <Tooltip title={tooltip}>{button}</Tooltip> + } else { + return button } +}) +Download.propTypes = { + fileName: PropTypes.string, + url: PropTypes.string, + component: PropTypes.any, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired, + disabled: PropTypes.bool, + tooltip: PropTypes.string, + color: PropTypes.string, + size: PropTypes.string } -export default compose(withApi(false), withErrors, withStyles(Download.styles))(Download) +export default Download diff --git a/gui/src/components/entry/EntryPage.js b/gui/src/components/entry/EntryPage.js index 4e53a30cfa61b156004d000d0fc4a34d423d8e89..9d9ae0020cc829422be8c770d733b0b865a6b361 100644 --- a/gui/src/components/entry/EntryPage.js +++ b/gui/src/components/entry/EntryPage.js @@ -40,8 +40,8 @@ The *log* tab will show you a log of the entry's processing. ` const TabRoutes = React.memo(function Routes({match}) { - const {params: {uploadId, entryId, tab = 'overview'}} = match - const props = {entryId: entryId, uploadId: uploadId} + const {params: {entryId, tab = 'overview'}} = match + const props = {entryId: entryId} if (!entryId) { return '' diff --git a/gui/src/components/entry/EntryQuery.js b/gui/src/components/entry/EntryQuery.js index 6ad45e378f10d570632e951a2ce1252fa1f3cfab..b26aca2d5df6d73c5bb238ea69fe40c89f87f5ff 100644 --- a/gui/src/components/entry/EntryQuery.js +++ b/gui/src/components/entry/EntryQuery.js @@ -15,99 +15,65 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' -import PropTypes from 'prop-types' -import { withStyles, Typography, Link } from '@material-ui/core' -import { compose } from 'recompose' -import { withApi, DoesNotExist } from '../api' -import { withRouter } from 'react-router' +import React, { useEffect, useMemo, useState } from 'react' +import { Typography, Link, makeStyles } from '@material-ui/core' +import { useHistory, useLocation } from 'react-router' import qs from 'qs' +import { useApi } from '../api' +import { useErrors } from '../errors' +import { getUrl } from '../nav/Routes' -class EntryQuery extends React.Component { - static styles = theme => ({ - root: { - padding: theme.spacing(3) - } - }) - - static propTypes = { - classes: PropTypes.object.isRequired, - api: PropTypes.object.isRequired, - history: PropTypes.object.isRequired, - location: PropTypes.object.isRequired, - raiseError: PropTypes.func.isRequired +const useStyles = makeStyles(theme => ({ + root: { + padding: theme.spacing(3) } +})) - static defaultState = { - doesNotExist: false, - queryParams: null - } +export default function EntryQuery(props) { + const classes = useStyles() + const history = useHistory() + const location = useLocation() + const {api} = useApi() + const {raiseError} = useErrors() - state = {...EntryQuery.defaultState} + const [doesNotExist, setDoesNotExist] = useState(false) - update() { - const { api, history, location } = this.props + const queryParams = useMemo(() => qs.parse(location.search.substring(1)), [location]) - let queryParams = null - if (location && location.search) { - queryParams = qs.parse(location.search.substring(1)) - } - api.search({...queryParams}).then(data => { - if (data.results && data.results.length > 0) { - const { calc_id, upload_id } = data.results[0] - history.push(`/entry/id/${upload_id}/${calc_id}`) - } else { - throw new DoesNotExist() - } - }).catch(error => { - if (error.name === 'DoesNotExist') { - this.setState({doesNotExist: true, queryParams: queryParams}) - } else { - this.props.raiseError(error) - } - }) - } + useEffect(() => { + api.get('/entries', {...queryParams}) + .then(response => { + if (response.pagination.total > 0) { + const {entry_id, upload_id} = response.data[0] + history.push(getUrl(`entry/id/${upload_id}/${entry_id}`)) + } else { + setDoesNotExist(true) + } + }) + .catch(raiseError) + }, [history, api, raiseError, setDoesNotExist, queryParams]) - componentDidMount() { - this.update() - } + let message = 'loading ...' - componentDidUpdate(prevProps) { - if (prevProps.location.search !== this.props.location.search || prevProps.api !== this.props.api) { - this.setState({...EntryQuery.defaultState}) - this.update() + if (doesNotExist) { + if (queryParams && queryParams['external_id'] && queryParams['external_id'].startsWith('mp-')) { + message = <React.Fragment> + This particular calculation <Link href={`https://materialsproject.org/tasks/${queryParams['external_id']}#`}> + {queryParams['external_id']} + </Link> has not yet been provided to NOMAD by the Materials Project. + </React.Fragment> + } else if (api.isLoggedIn) { + message = ` + This URL points to an entry that either does not exist, or that you are not + authorized to see.` + } else { + message = ` + This URL points to an entry that either does not exist, or that is not + publically visibile. Please login; you might be authorized to view it.` } } - render() { - const { classes, api } = this.props - const { doesNotExist, queryParams } = this.state - - let message = 'loading ...' - - if (doesNotExist) { - console.log(queryParams) - if (queryParams && queryParams['external_id'] && queryParams['external_id'].startsWith('mp-')) { - message = <React.Fragment> - This particular calculation <Link href={`https://materialsproject.org/tasks/${queryParams['external_id']}#`}> - {queryParams['external_id']} - </Link> has not yet been provided to NOMAD by the Materials Project. - </React.Fragment> - } else if (api.isLoggedIn) { - message = ` - This URL points to an entry that either does not exist, or that you are not - authorized to see.` - } else { - message = ` - This URL points to an entry that either does not exist, or that is not - publically visibile. Please login; you might be authorized to view it.` - } - } - - return ( - <Typography className={classes.root}>{message}</Typography> - ) - } + return ( + <Typography className={classes.root}>{message}</Typography> + ) } - -export default compose(withRouter, withApi(false), withStyles(EntryQuery.styles))(EntryQuery) diff --git a/gui/src/components/entry/OverviewView.js b/gui/src/components/entry/OverviewView.js index 963b48d155a777398ee6031d0184ea080a76352d..6057038a8fd9418c33d2c0bdb87a30d224f8b0a5 100644 --- a/gui/src/components/entry/OverviewView.js +++ b/gui/src/components/entry/OverviewView.js @@ -17,7 +17,7 @@ */ import React, { useState, useEffect } from 'react' import PropTypes from 'prop-types' -import { useApi } from '../apiV1' +import { useApi } from '../api' import { useErrors } from '../errors' import { Typography, makeStyles, Box, Grid, Link, Divider } from '@material-ui/core' import { ApiDialog } from '../ApiDialogButton' @@ -78,7 +78,7 @@ const useStyles = makeStyles(theme => ({ /** * Shows an informative overview about the selected entry. */ -const OverviewView = React.memo(function OverviewView({uploadId, entryId, ...moreProps}) { +const OverviewView = React.memo(function OverviewView({entryId, ...moreProps}) { const { raiseError } = useErrors() const [entry, setEntry] = useState(null) const [exists, setExists] = useState(true) @@ -214,7 +214,6 @@ const OverviewView = React.memo(function OverviewView({uploadId, entryId, ...mor }) OverviewView.propTypes = { - uploadId: PropTypes.string.isRequired, entryId: PropTypes.string.isRequired } diff --git a/gui/src/components/entry/OverviewView.spec.js b/gui/src/components/entry/OverviewView.spec.js index dca55f08c56b021bef75b8dea84b1bfb86e3495a..9cc0a448058f01dfefe20dbef084687bc1666f06 100644 --- a/gui/src/components/entry/OverviewView.spec.js +++ b/gui/src/components/entry/OverviewView.spec.js @@ -25,10 +25,10 @@ import '@testing-library/jest-dom/extend-expect' import { repoDftBulk } from '../../../tests/DFTBulk' -import { useApi } from '../apiV1' +import { useApi } from '../api' import OverviewView from './OverviewView' -jest.mock('../apiV1') +jest.mock('../api') beforeAll(() => { useApi.mockReturnValue({ @@ -42,7 +42,7 @@ beforeAll(() => { }) }) -afterAll(() => jest.unmock('../apiV1')) +afterAll(() => jest.unmock('../api')) function expectPlotButtons(plot) { expect(within(plot).getByRole('button', {name: 'Reset view'})).toBeInTheDocument() diff --git a/gui/src/components/entry/RawFileView.js b/gui/src/components/entry/RawFileView.js index 8184ef825920e9869b5507597e6d199b77b4fb6b..a450afd59159c175d5d973ce3ea9a284b62787a8 100644 --- a/gui/src/components/entry/RawFileView.js +++ b/gui/src/components/entry/RawFileView.js @@ -19,7 +19,7 @@ import React, { useContext, useState, useEffect } from 'react' import PropTypes from 'prop-types' import { Typography, makeStyles, Card, CardHeader, CardContent } from '@material-ui/core' import { errorContext } from '../errors' -import { useApi } from '../apiV1' +import { useApi } from '../api' import RawFiles from './RawFiles' import Page from '../Page' @@ -29,7 +29,7 @@ const useStyles = makeStyles(theme => ({ } })) -export default function RawFileView({uploadId, entryId}) { +export default function RawFileView({entryId}) { const classes = useStyles() const {raiseError} = useContext(errorContext) const [state, setState] = useState({entryData: null, doesNotExist: false}) @@ -37,7 +37,7 @@ export default function RawFileView({uploadId, entryId}) { useEffect(() => { setState({entryData: null, doesNotExist: false}) - }, [setState, uploadId, entryId]) + }, [setState, entryId]) useEffect(() => { api.entry(entryId).then(entry => { @@ -52,7 +52,7 @@ export default function RawFileView({uploadId, entryId}) { }) }, [api, raiseError, entryId, setState]) - const entryData = state.entryData || {uploadId: uploadId, entryId: entryId} + const entryData = state.entryData || {entryId: entryId} if (state.doesNotExist) { return <Page> @@ -67,7 +67,7 @@ export default function RawFileView({uploadId, entryId}) { <Card className={classes.root}> <CardHeader title="Raw files" /> <CardContent> - <RawFiles data={entryData} entryId={entryId} uploadId={uploadId} /> + <RawFiles data={entryData} entryId={entryId} /> </CardContent> </Card> </Page> @@ -75,6 +75,5 @@ export default function RawFileView({uploadId, entryId}) { } RawFileView.propTypes = { - uploadId: PropTypes.string.isRequired, entryId: PropTypes.string.isRequired } diff --git a/gui/src/components/entry/RawFiles.js b/gui/src/components/entry/RawFiles.js index c6c74ca2aaaa664f4ca1837a7e9d7f0820c42312..39f663006ccbc67a3bb4e88bb3a6f1f43046578c 100644 --- a/gui/src/components/entry/RawFiles.js +++ b/gui/src/components/entry/RawFiles.js @@ -30,14 +30,13 @@ import { Tooltip } from '@material-ui/core' import DownloadIcon from '@material-ui/icons/CloudDownload' -import { withApi } from '../api' -import { compose } from 'recompose' import Download from './Download' import ReloadIcon from '@material-ui/icons/Cached' import ViewIcon from '@material-ui/icons/Search' import InfiniteScroll from 'react-infinite-scroller' import { ScrollContext } from '../nav/Navigation' -import { useApi } from '../apiV1' +import { useApi } from '../api' +import { useErrors } from '../errors' const useStyles = makeStyles(theme => ({ root: {}, @@ -92,7 +91,7 @@ function label(file) { return file.split('/').reverse()[0] } -function RawFiles({api, user, data, uploadId, entryId, raiseError}) { +export default function RawFiles({data, entryId}) { const theme = useTheme() const classes = useStyles(theme) const [selectedFiles, setSelectedFiles] = useState([]) @@ -101,7 +100,8 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) { const [files, setFiles] = useState(null) const [loading, setLoading] = useState(false) const [doesNotExist, setDoesNotExist] = useState(false) - const {api: apiv1} = useApi() + const {raiseError} = useErrors() + const {api, user} = useApi() useEffect(() => { setSelectedFiles([]) @@ -110,16 +110,16 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) { setFiles(null) setLoading(false) setDoesNotExist(false) - }, [api, uploadId, entryId]) + }, [api, entryId]) const update = useCallback(() => { // this might accidentally happen, when the user logs out and the ids aren't // necessarily available anymore, but the component is still mounted - if (!uploadId || !entryId) { + if (!entryId) { return } - apiv1.getRawFileListFromCalc(entryId).then(data => { + api.getRawFileListFromCalc(entryId).then(data => { const files = data.data.files.map(file => file.path) if (files.length > 500) { raiseError('There are more than 500 files in this entry. We can only show the first 500.') @@ -136,7 +136,7 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) { } }) setLoading(true) - }, [uploadId, entryId, raiseError, apiv1]) + }, [entryId, raiseError, api]) const handleSelectFile = useCallback((file) => { setSelectedFiles(prevState => { @@ -153,10 +153,13 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) { const handleFileClicked = useCallback(file => { setShownFile(file) setFileContents(null) - api.getRawFile(uploadId, entryId, file.split('/').reverse()[0], {length: 16 * 1024}) - .then(contents => setFileContents(contents)) + api.get(`/entries/${entryId}/raw/download/${file.split('/').reverse()[0]}`, {length: 16 * 1024, decompress: true}) + .then(contents => setFileContents({ + hasMore: true, + contents: contents + })) .catch(raiseError) - }, [api, raiseError, uploadId, entryId]) + }, [api, raiseError, entryId]) const handleLoadMore = useCallback((page) => { // The infinite scroll component has the issue if calling load more whenever it @@ -168,15 +171,15 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) { const initialEntryId = entryId if (fileContents.contents.length < (page + 1) * 16 * 1024) { - api.getRawFile(uploadId, entryId, shownFile.split('/').reverse()[0], {offset: page * 16 * 1024, length: 16 * 1024}) + api.get(`/entries/${entryId}/raw/download/${shownFile.split('/').reverse()[0]}`, {offset: page * 16 * 1024, length: 16 * 1024, decompress: true}) .then(contents => { // The back-button navigation might cause a scroll event, might cause to loadmore, // will set this state, after navigation back to this page, but potentially // different entry. if (initialEntryId === entryId) { setFileContents({ - ...contents, - contents: ((fileContents && fileContents.contents) || '') + contents.contents + hasMore: contents.length > 0, + contents: ((fileContents && fileContents.contents) || '') + contents }) } }) @@ -186,7 +189,7 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) { raiseError(error) }) } - }, [api, uploadId, entryId, shownFile, fileContents, raiseError]) + }, [api, entryId, shownFile, fileContents, raiseError]) const filterPotcar = useCallback((file) => { if (file.substring(file.lastIndexOf('/')).includes('POTCAR') && !file.endsWith('.stripped')) { @@ -207,18 +210,19 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) { </Typography> } + const file = path => path.substring(path.lastIndexOf('/') + 1) + let downloadUrl if (selectedFiles.length === 1) { // download the individual file - downloadUrl = `raw/${uploadId}/${selectedFiles[0]}` + downloadUrl = `entries/${entryId}/raw/download/${file(selectedFiles[0])}` } else if (selectedFiles.length === availableFiles.length) { // use an endpoint that downloads all files of the calc - downloadUrl = `raw/calc/${uploadId}/${entryId}/*?strip=true` + downloadUrl = `entries/${entryId}/raw/download` } else if (selectedFiles.length > 0) { - // use a prefix to shorten the url - const prefix = selectedFiles[0].substring(0, selectedFiles[0].lastIndexOf('/')) - const files = selectedFiles.map(path => path.substring(path.lastIndexOf('/') + 1)).join(',') - downloadUrl = `raw/${uploadId}?files=${encodeURIComponent(files)}&prefix=${prefix}&strip=true` + // download specific files + const query = selectedFiles.map(file).map(f => `include_files=${encodeURIComponent(f)}`).join('&') + downloadUrl = `entries/${entryId}/raw/download?${query}` } return ( @@ -310,16 +314,7 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) { </div> ) } - RawFiles.propTypes = { - uploadId: PropTypes.string.isRequired, entryId: PropTypes.string.isRequired, - data: PropTypes.object, - api: PropTypes.object.isRequired, - user: PropTypes.object, - raiseError: PropTypes.func.isRequired + data: PropTypes.object } - -export default compose( - withApi(false, true) -)(RawFiles) diff --git a/gui/src/components/entry/ResolvePID.js b/gui/src/components/entry/ResolvePID.js index b2ab1b782156e0e12c4f49f68709c191851e879c..f0983c2d7d145cb0cbc7478355109ecb5ca7312c 100644 --- a/gui/src/components/entry/ResolvePID.js +++ b/gui/src/components/entry/ResolvePID.js @@ -15,87 +15,63 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' -import PropTypes from 'prop-types' -import { withStyles, Typography } from '@material-ui/core' -import { compose } from 'recompose' -import { withApi } from '../api' -import { withRouter, matchPath } from 'react-router' +import React, { useEffect, useState } from 'react' +import { Typography, makeStyles } from '@material-ui/core' +import { matchPath, useLocation, useRouteMatch, useHistory } from 'react-router' +import {useApi} from '../api' +import {useErrors} from '../errors' +import { getUrl } from '../nav/Routes' -class ResolvePID extends React.Component { - static styles = theme => ({ - root: { - padding: theme.spacing(3) - } - }) - - static propTypes = { - classes: PropTypes.object.isRequired, - location: PropTypes.object.isRequired, - match: PropTypes.object.isRequired, - history: PropTypes.object.isRequired, - api: PropTypes.object.isRequired, - raiseError: PropTypes.func.isRequired +const useStyles = makeStyles(theme => ({ + root: { + padding: theme.spacing(3) } +})) - static defaultState = { - doesNotExist: false - } +export default function ResolvePID() { + const classes = useStyles() + const {api, user} = useApi() + const {raiseError} = useErrors() + const history = useHistory() + const location = useLocation() + const match = useRouteMatch() - state = {...ResolvePID.defaultState} + const [doesNotExist, setDoesNotExist] = useState(false) - update() { - const { location, match, api, history } = this.props + useEffect(() => { const pidMatch = matchPath(location.pathname, { path: `${match.path}/:pid/:handle?` }) let { pid, handle } = pidMatch.params pid = handle ? pid + '/' + handle : pid - api.resolvePid(pid).then(entry => { - history.push(`/entry/id/${entry.upload_id}/${entry.calc_id}`) - }).catch(error => { - if (error.name === 'DoesNotExist') { - this.setState({doesNotExist: true}) - } else { - this.props.raiseError(error) - } - }) - } + api.post('/entries/query', {owner: 'all', query: {pid: pid}}) + .then(response => { + if (response.pagination.total >= 1) { + const entry = response.data[0] + history.push(getUrl(`entry/id/${entry.upload_id}/${entry.calc_id}`, location)) + } else { + setDoesNotExist(true) + } + }) + .catch(raiseError) + }, [setDoesNotExist, history, location, match, api, raiseError]) - componentDidMount() { - this.update() - } + let message = 'loading ...' - componentDidUpdate(prevProps) { - if (prevProps.location.pathname !== this.props.location.pathname || prevProps.api !== this.props.api) { - this.setState({...ResolvePID.defaultState}) - this.update() + if (doesNotExist) { + if (user) { + message = ` + This URL points to an entry that either does not exist, or that you are not + authorized to see.` + } else { + message = ` + This URL points to an entry that either does not exist, or that is not + publically visibile. Please login; you might be authorized to view it.` } } - render() { - const { classes, api } = this.props - const { doesNotExist } = this.state - - let message = 'loading ...' - - if (doesNotExist) { - if (api.isLoggedIn) { - message = ` - This URL points to an entry that either does not exist, or that you are not - authorized to see.` - } else { - message = ` - This URL points to an entry that either does not exist, or that is not - publically visibile. Please login; you might be authorized to view it.` - } - } - - return ( - <Typography className={classes.root}>{message}</Typography> - ) - } + return ( + <Typography className={classes.root}>{message}</Typography> + ) } - -export default compose(withRouter, withApi(false), withStyles(ResolvePID.styles))(ResolvePID) diff --git a/gui/src/components/entry/properties/MaterialCard.js b/gui/src/components/entry/properties/MaterialCard.js index a0d8ccc5d5a329292960d5c6a7a4c2839ea28146..de774e9e0f1609b51f0068b398587494655420c9 100644 --- a/gui/src/components/entry/properties/MaterialCard.js +++ b/gui/src/components/entry/properties/MaterialCard.js @@ -63,7 +63,7 @@ export default function MaterialCard({entryMetadata, archive}) { const materialId = entryMetadata.results?.material?.material_id let structures = null - if (archive) { + if (archive?.results) { structures = [] const structureKinds = ['original', 'conventional', 'primitive'] const archiveStructures = archive.results.properties?.structures diff --git a/gui/src/components/material/MaterialPage.js b/gui/src/components/material/MaterialPage.js deleted file mode 100644 index f29a3727c7270e20f37bf1d8bbfe50b043de47d1..0000000000000000000000000000000000000000 --- a/gui/src/components/material/MaterialPage.js +++ /dev/null @@ -1,78 +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, { useEffect, useState } from 'react' -import PropTypes from 'prop-types' -import { Box } from '@material-ui/core' -import ElectronicStructureOverview from './ElectronicStructureOverview' -import { useRouteMatch, Route } from 'react-router-dom' -import { withApi } from '../api' - -const MaterialPageContent = withApi(false, true)(({fixed, api, materialId, raiseError}) => { - const props = fixed ? {maxWidth: 1200} : {} - const [data, setData] = useState() - - // Load the data parallelly from API on first render - useEffect(() => { - Promise.all([ - api.encyclopediaBasic(materialId), - api.encyclopediaCalculations(materialId) - ]).then((results) => { - setData({ - basic: results[0], - calculations: results[1] - }) - }).catch(error => { - if (error.name === 'DoesNotExist') { - raiseError(error) - } - }) - }, [api, materialId, raiseError]) - - return <Box padding={3} margin="auto" {...props}> - <ElectronicStructureOverview data={data}></ElectronicStructureOverview> - </Box> -}) -MaterialPageContent.propTypes = ({ - materialId: PropTypes.string, - api: PropTypes.func, - raiseError: PropTypes.func, - fixed: PropTypes.bool -}) -function MaterialPage() { - const { path } = useRouteMatch() - - return ( - <Route - path={`${path}/:materialId?/:tab?`} - render={({match: {params: {materialId, tab = 'overview'}}}) => { - if (materialId) { - return ( - <React.Fragment> - <MaterialPageContent fixed={true} materialId={materialId}> - </MaterialPageContent> - </React.Fragment> - ) - } else { - return '' - } - }} - /> - ) -} - -export default MaterialPage diff --git a/gui/src/components/nav/AppBar.js b/gui/src/components/nav/AppBar.js index 913344dee7b8c4d05d313bafb046b3e672045775..f075e12714f4377bf0deeadaea1701af2e284489 100644 --- a/gui/src/components/nav/AppBar.js +++ b/gui/src/components/nav/AppBar.js @@ -28,7 +28,7 @@ import { import LoginLogout from '../LoginLogout' import UnitSelector from '../UnitSelector' import MainMenu from './MainMenu' -import { useLoading } from '../apiV1' +import { useLoading } from '../api' import { guiBase } from '../../config' import Breadcrumbs from './Breadcrumbs' diff --git a/gui/src/components/nav/Navigation.js b/gui/src/components/nav/Navigation.js index 989b491d9d9627dc81ff19f02629b76d27e72dc1..21ce0fe6f8b4cfcf9a19e5ff849c2a7addb6c037 100644 --- a/gui/src/components/nav/Navigation.js +++ b/gui/src/components/nav/Navigation.js @@ -25,7 +25,6 @@ import { amber } from '@material-ui/core/colors' import AppBar, { appBarHeight } from './AppBar' import { version } from '../../config' import { Routes } from './Routes' -import { withApi } from '../api' import { serviceWorkerUpdateHandlerRef } from '../../serviceWorker' import { ErrorBoundary } from '../errors' @@ -134,7 +133,7 @@ const useStyles = makeStyles(theme => ({ } })) -function Navigation() { +export default function Navigation() { const classes = useStyles() const scrollParentRef = useRef(null) @@ -155,5 +154,3 @@ function Navigation() { </div> ) } - -export default withApi(false)(Navigation) diff --git a/gui/src/components/nav/Routes.js b/gui/src/components/nav/Routes.js index be8d7c78f515db6a2c2cf166286e0e33eaebce38..11dd7ed32512ee2021a619cfd07ab7cc66bcd4d3 100644 --- a/gui/src/components/nav/Routes.js +++ b/gui/src/components/nav/Routes.js @@ -373,7 +373,6 @@ export function getUrl(path, location) { return `${url}/${path}` } -routes.forEach(route => addRoute(route, '')) export const RouteButton = React.forwardRef(function RouteButton(props, ref) { const {component, path, ...moreProps} = props diff --git a/gui/src/components/search/EntryList.js b/gui/src/components/search/EntryList.js index 9368a9c385cd5b5352c63a96f8abc767a6a911f4..4c1bcd88215cc750319211c1b23e3defb0a0d545 100644 --- a/gui/src/components/search/EntryList.js +++ b/gui/src/components/search/EntryList.js @@ -15,7 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useContext } from 'react' +import React from 'react' import PropTypes from 'prop-types' import { withStyles, Link, Typography, Tooltip, IconButton, TablePagination } from '@material-ui/core' import { compose } from 'recompose' @@ -31,17 +31,17 @@ import UploaderIcon from '@material-ui/icons/AccountCircle' import SharedIcon from '@material-ui/icons/SupervisedUserCircle' import PrivateIcon from '@material-ui/icons/VisibilityOff' import { domainData } from '../domainData' -import { apiContext, withApi } from '../api' import { authorList, nameList } from '../../utils' import EntryDetails from '../entry/EntryDetails' import { EntryButton } from '../nav/Routes' +import { useApi, withApi } from '../api' export function Published(props) { - const api = useContext(apiContext) + const {user} = useApi() const {entry} = props if (entry.published) { if (entry.with_embargo) { - if (api.user && entry.uploader.user_id === api.user.sub) { + if (user && entry.uploader.user_id === user.sub) { if (entry.owners.length === 1) { return <Tooltip title="published with embargo by you and only accessible by you"> <UploaderIcon color="error" /> @@ -51,12 +51,12 @@ export function Published(props) { <SharedIcon color="error" /> </Tooltip> } - } else if (api.user && entry.owners.find(user => user.user_id === api.user.sub)) { + } else if (user && entry.owners.find(user => user.user_id === user.sub)) { return <Tooltip title="published with embargo and shared with you"> <SharedIcon color="error" /> </Tooltip> } else { - if (api.user) { + if (user) { return <Tooltip title="published with embargo and not accessible by you"> <PrivateIcon color="error" /> </Tooltip> @@ -424,6 +424,6 @@ export class EntryListUnstyled extends React.Component { } } -const EntryList = compose(withRouter, withApi(false, false), withStyles(EntryListUnstyled.styles))(EntryListUnstyled) +const EntryList = withApi(compose(withRouter, withStyles(EntryListUnstyled.styles))(EntryListUnstyled)) export default EntryList diff --git a/gui/src/components/search/SearchBar.js b/gui/src/components/search/SearchBar.js index 72f6a221db14a80262c76fe604f593fbb1452374..336860a5ca42c20fa237afc41ca091035d04ec11 100644 --- a/gui/src/components/search/SearchBar.js +++ b/gui/src/components/search/SearchBar.js @@ -32,7 +32,7 @@ import { Typography } from '@material-ui/core' import IconButton from '@material-ui/core/IconButton' -import { useApi } from '../apiV1' +import { useApi } from '../api' import { useUnits } from '../../units' import { isMetaNumber, isMetaTimestamp } from '../../utils' import { diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js index af4c4fe1191d91869623e08d1bc12072434b9191..ba9eecdfee56a4198f786d9481d9b1696e502d3c 100644 --- a/gui/src/components/search/SearchContext.js +++ b/gui/src/components/search/SearchContext.js @@ -36,7 +36,7 @@ import { import qs from 'qs' import PropTypes from 'prop-types' import { useHistory } from 'react-router-dom' -import { useApi } from '../apiV1' +import { useApi } from '../api' import { setToArray, formatMeta, parseMeta } from '../../utils' import searchQuantities from '../../searchQuantities' import { Quantity } from '../../units' diff --git a/gui/src/components/search/input/InputText.js b/gui/src/components/search/input/InputText.js index 5c2b5a6b602cee68db20d5aa96bf29ee1a1f588b..6b82ed03dea4b235bdabf6acb446536cc3fa378d 100644 --- a/gui/src/components/search/input/InputText.js +++ b/gui/src/components/search/input/InputText.js @@ -29,7 +29,7 @@ import CloseIcon from '@material-ui/icons/Close' import PropTypes from 'prop-types' import clsx from 'clsx' import { Unit } from '../../../units' -import { useApi } from '../../apiV1' +import { useApi } from '../../api' import searchQuantities from '../../../searchQuantities' import InputLabel from './InputLabel' import InputTooltip from './InputTooltip' diff --git a/gui/src/components/search/menus/FilterSubMenuAccess.js b/gui/src/components/search/menus/FilterSubMenuAccess.js index 11785d6146da5b682dc0fe61b63120098b6aa938..fbefaefb39fbf687575bffe08664ac2e1b31f681 100644 --- a/gui/src/components/search/menus/FilterSubMenuAccess.js +++ b/gui/src/components/search/menus/FilterSubMenuAccess.js @@ -20,7 +20,7 @@ import PropTypes from 'prop-types' import { Grid } from '@material-ui/core' import { FilterSubMenu } from './FilterMenu' import InputRadio from '../input/InputRadio' -import { useApi } from '../../apiV1' +import { useApi } from '../../api' const FilterSubMenuAccess = React.memo(({ value, diff --git a/gui/src/components/search/results/DatasetList.js b/gui/src/components/search/results/DatasetList.js index 51f63802a08693ebbac0398bf9b1f1a3f3a1a456..aaf192e522e7b032fa4be09b7c9b87843883910f 100644 --- a/gui/src/components/search/results/DatasetList.js +++ b/gui/src/components/search/results/DatasetList.js @@ -17,24 +17,9 @@ */ import React from 'react' import PropTypes from 'prop-types' -import { withStyles, TableCell, Toolbar, IconButton, FormGroup, Tooltip, Link } from '@material-ui/core' -import { compose } from 'recompose' -import { withRouter } from 'react-router' -import NextIcon from '@material-ui/icons/ChevronRight' -import StartIcon from '@material-ui/icons/SkipPrevious' -import DataTable from '../../DataTable' -import SearchIcon from '@material-ui/icons/Search' -import DOIIcon from '@material-ui/icons/Bookmark' -import DeleteIcon from '@material-ui/icons/Delete' -import { withApi } from '../../api' -import EditUserMetadataDialog from '../../EditUserMetadataDialog' -import DownloadButton from '../../DownloadButton' +import { withStyles, IconButton, Tooltip, Link } from '@material-ui/core' import ClipboardIcon from '@material-ui/icons/Assignment' import { CopyToClipboard } from 'react-copy-to-clipboard' -import ConfirmDialog from '../../uploads/ConfirmDialog' -import { oasis } from '../../../config' -import { authorList } from '../../../utils' -import { DatasetButton } from '../../nav/Routes' class DOIUnstyled extends React.Component { static propTypes = { @@ -77,247 +62,3 @@ class DOIUnstyled extends React.Component { } export const DOI = withStyles(DOIUnstyled.styles)(DOIUnstyled) - -class DatasetActionsUnstyled extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - dataset: PropTypes.object.isRequired, - history: PropTypes.object.isRequired, - search: PropTypes.bool, - user: PropTypes.object, - onChange: PropTypes.func, - api: PropTypes.object.isRequired, - raiseError: PropTypes.func.isRequired - } - - static styles = theme => ({ - group: { - flexWrap: 'nowrap', - flexDirection: 'row-reverse' - } - }) - - state = { - confirmDoi: false - } - - constructor(props) { - super(props) - this.handleClickDOI = this.handleClickDOI.bind(this) - this.handleClickDelete = this.handleClickDelete.bind(this) - this.handleEdit = this.handleEdit.bind(this) - } - - handleClickDOI(after) { - const {api, dataset, onChange, raiseError} = this.props - const datasetName = dataset.name - - api.assignDatasetDOI(datasetName) - .then(dataset => { - if (onChange) { - onChange(dataset) - } - if (after) { - after() - } - }) - .catch(raiseError) - } - - handleEdit() { - const {onChange, dataset} = this.props - if (onChange) { - onChange(dataset) - } - } - - handleClickDelete() { - const {api, dataset, onChange, raiseError} = this.props - const datasetName = dataset.name - - api.deleteDataset(datasetName) - .then(dataset => { - if (onChange) { - onChange(null) - } - }) - .catch(raiseError) - } - - render() { - const {dataset, search, user, classes} = this.props - const {doi} = dataset - const editable = user && dataset.example && - dataset.example.owners.find(author => author.user_id === user.sub) - - const canAssignDOI = !doi - const canDelete = !doi - const query = {dataset_id: [dataset.dataset_id]} - - return <FormGroup row classes={{root: classes.group}}> - {search && <Tooltip title="Open a search page with entries from this dataset only."> - <DatasetButton component={IconButton} datasetId={dataset.dataset_id}> - <SearchIcon /> - </DatasetButton> - </Tooltip>} - {<DownloadButton query={query} tooltip="Download dataset" />} - {editable && canDelete && <Tooltip title="Delete this dataset."> - <IconButton onClick={this.handleClickDelete}> - <DeleteIcon /> - </IconButton> - </Tooltip>} - {editable && <EditUserMetadataDialog - title="Edit metadata of all dataset entries" - example={dataset.example} query={query} - total={dataset.total} onEditComplete={this.handleEdit} - />} - {!oasis && editable && canAssignDOI && <Tooltip title="Assign a DOI to this dataset."> - <IconButton onClick={() => this.setState({confirmDoi: true})}> - <DOIIcon /> - </IconButton> - </Tooltip>} - <ConfirmDialog - open={this.state.confirmDoi} - title="Assign a DOI" - content={` - DOIs are **permanent**. Are you sure that you want to assign a DOI to this - dataset? Once the DOI was assigned, entries cannot removed from the dataset and - the dataset cannot be deleted. - `} - onClose={() => this.setState({confirmDoi: false})} - onConfirm={() => { - this.handleClickDOI(() => this.setState({confirmDoi: false})) - }} - /> - </FormGroup> - } -} - -export const DatasetActions = compose(withRouter, withApi(false), withStyles(DatasetActionsUnstyled.styles))(DatasetActionsUnstyled) - -class DatasetListUnstyled extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - data: PropTypes.object, - total: PropTypes.number, - onChange: PropTypes.func.isRequired, - onEdit: PropTypes.func.isRequired, - history: PropTypes.any.isRequired, - datasets_after: PropTypes.string, - per_page: PropTypes.number, - actions: PropTypes.element - } - - static styles = theme => ({ - root: { - overflow: 'auto', - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2) - }, - scrollCell: { - padding: 0 - }, - scrollBar: { - minHeight: 56, - padding: 0 - }, - scrollSpacer: { - flexGrow: 1 - }, - clickableRow: { - cursor: 'pointer' - } - }) - - constructor(props) { - super(props) - this.renderEntryActions = this.renderEntryActions.bind(this) - } - - columns = { - name: { - label: 'Dataset name', - description: 'The name given to this dataset by its creator', - render: (dataset) => dataset.name - }, - created: { - label: 'Created', - description: 'The data when this dataset was created', - render: (dataset) => dataset.created && new Date(dataset.created).toLocaleString() - }, - DOI: { - label: 'Dataset DOI', - description: 'The DOI of the dataset, if a DOI was assigned', - render: (dataset) => dataset.doi && <DOI doi={dataset.doi} /> - }, - entries: { - label: 'Entries', - description: 'Number of entries that comprise the group', - render: (dataset) => dataset.total - }, - authors: { - label: 'Authors', - description: 'Authors including the uploader and the co-authors', - render: (dataset) => authorList(dataset.example) - } - } - - renderEntryActions(entry) { - const {onEdit} = this.props - return <DatasetActions search dataset={entry} onChange={onEdit} /> - } - - render() { - const { classes, data, total, datasets_after, per_page, onChange, actions } = this.props - const datasets = data.datasets_grouped || {values: []} - const results = Object.keys(datasets.values).map(id => { - const exampleDataset = datasets.values[id].examples[0].datasets.find(ds => ds.dataset_id === id) - return { - ...exampleDataset, - id: id, - total: datasets.values[id].total, - example: datasets.values[id].examples[0] - } - }) - const after = datasets.after - const perPage = per_page || 10 - - let paginationText - if (datasets_after) { - paginationText = `next ${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}` - } else { - paginationText = `1-${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}` - } - - const pagination = <TableCell colSpan={1000} classes={{root: classes.scrollCell}}> - <Toolbar className={classes.scrollBar}> - <span className={classes.scrollSpacer}> </span> - <span>{paginationText}</span> - <IconButton disabled={!datasets_after} onClick={() => onChange({datasets_grouped_after: null})}> - <StartIcon /> - </IconButton> - <IconButton disabled={results.length < perPage} onClick={() => onChange({datasets_grouped_after: after})}> - <NextIcon /> - </IconButton> - </Toolbar> - </TableCell> - - return <DataTable - entityLabels={['dataset', 'datasets']} - id={row => row.id} - total={total} - columns={this.columns} - selectedColumns={['name', 'DOI', 'entries', 'authors']} - selectedColumnsKey="datasets" - entryActions={this.renderEntryActions} - data={results} - rows={perPage} - actions={actions} - pagination={pagination} - /> - } -} - -const DatasetList = compose(withRouter, withApi(false), withStyles(DatasetListUnstyled.styles))(DatasetListUnstyled) - -export default DatasetList diff --git a/gui/src/components/search/results/SearchResultsEntries.js b/gui/src/components/search/results/SearchResultsEntries.js index d82a5671021e3bdf5fa3ef372c0658e5db1887d0..2857b20f22e94dd423a8aaeeb4e85b54c872be76 100644 --- a/gui/src/components/search/results/SearchResultsEntries.js +++ b/gui/src/components/search/results/SearchResultsEntries.js @@ -31,7 +31,7 @@ import { domainData } from '../../domainData' import EntryDetails from '../../entry/EntryDetails' import { authorList, nameList } from '../../../utils' import NewDataTable from '../../NewDataTable' -import { useApi } from '../../apiV1' +import { useApi } from '../../api' import { EntryButton } from '../../nav/Routes' import Quantity from '../../Quantity' import searchQuantities from '../../../searchQuantities' diff --git a/gui/src/components/search/results/UploadsList.js b/gui/src/components/search/results/UploadsList.js deleted file mode 100644 index 49ce3390cc5eac858e74c38821b28f617c53cab5..0000000000000000000000000000000000000000 --- a/gui/src/components/search/results/UploadsList.js +++ /dev/null @@ -1,263 +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 from 'react' -import PropTypes from 'prop-types' -import { withStyles, TableCell, Toolbar, IconButton, FormGroup, Tooltip } from '@material-ui/core' -import { compose } from 'recompose' -import { withRouter } from 'react-router' -import NextIcon from '@material-ui/icons/ChevronRight' -import StartIcon from '@material-ui/icons/SkipPrevious' -import DataTable from '../../DataTable' -import { withApi } from '../../api' -import EditUserMetadataDialog from '../../EditUserMetadataDialog' -import DownloadButton from '../../DownloadButton' -import ClipboardIcon from '@material-ui/icons/Assignment' -import { CopyToClipboard } from 'react-copy-to-clipboard' -import DetailsIcon from '@material-ui/icons/MoreHoriz' -import PublicIcon from '@material-ui/icons/Public' -import UploaderIcon from '@material-ui/icons/AccountCircle' -import { UploadButton } from '../../nav/Routes' - -export function Published(props) { - const {entry} = props - if (entry.published) { - return <Tooltip title="published upload"> - <PublicIcon color="primary" /> - </Tooltip> - } else { - return <Tooltip title="this upload is not yet published"> - <UploaderIcon color="error"/> - </Tooltip> - } -} - -class UploadIdUnstyled extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - uploadId: PropTypes.string.isRequired - } - - static styles = theme => ({ - root: { - display: 'inline-flex', - alignItems: 'center', - flexDirection: 'row', - flexWrap: 'nowrap' - } - }) - - render() { - const {classes, uploadId} = this.props - return <span className={classes.root}> - {uploadId} - <CopyToClipboard - text={uploadId} onCopy={() => null} - > - <Tooltip title={`Copy to clipboard`}> - <IconButton style={{margin: 3, marginRight: 0, padding: 4}}> - <ClipboardIcon style={{fontSize: 16}} /> - </IconButton> - </Tooltip> - </CopyToClipboard> - </span> - } -} - -export const UploadId = withStyles(UploadIdUnstyled.styles)(UploadIdUnstyled) - -class UploadActionsUnstyled extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - upload: PropTypes.object.isRequired, - user: PropTypes.object, - onEdit: PropTypes.func, - history: PropTypes.object.isRequired - } - - static styles = theme => ({ - group: { - flexWrap: 'nowrap', - flexDirection: 'row-reverse' - } - }) - - constructor(props) { - super(props) - this.handleEdit = this.handleEdit.bind(this) - } - - handleEdit() { - const {onEdit, upload} = this.props - if (onEdit) { - onEdit(upload) - } - } - - render() { - const {upload, user, classes} = this.props - const editable = user && upload.example && - upload.example.authors.find(author => author.user_id === user.sub) - const uploadId = upload.example.upload_id - const query = {upload_id: [uploadId]} - - return <FormGroup row classes={{root: classes.group}}> - {user.sub === upload.example.uploader.user_id && - <Tooltip title="Open this upload on the uploads page"> - <UploadButton component={IconButton} uploadId={uploadId}> - <DetailsIcon /> - </UploadButton> - </Tooltip>} - {<DownloadButton query={query} tooltip="Download upload" />} - {editable && <EditUserMetadataDialog - title="Edit metadata of all entries in this upload" - example={upload.example} query={query} - total={upload.total} onEditComplete={this.handleEdit} - />} - </FormGroup> - } -} - -export const UploadActions = compose(withRouter, withApi(false), withStyles(UploadActionsUnstyled.styles))(UploadActionsUnstyled) - -class UploadListUnstyled extends React.Component { - static propTypes = { - classes: PropTypes.object.isRequired, - data: PropTypes.object, - total: PropTypes.number, - onChange: PropTypes.func.isRequired, - onEdit: PropTypes.func.isRequired, - history: PropTypes.any.isRequired, - uploads_after: PropTypes.string, - actions: PropTypes.element - } - - static styles = theme => ({ - root: { - overflow: 'auto', - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2) - }, - scrollCell: { - padding: 0 - }, - scrollBar: { - minHeight: 56, - padding: 0 - }, - scrollSpacer: { - flexGrow: 1 - }, - clickableRow: { - cursor: 'pointer' - } - }) - - constructor(props) { - super(props) - this.renderEntryActions = this.renderEntryActions.bind(this) - } - - columns = { - upload_time: { - label: 'Upload time', - render: (upload) => new Date(upload.example.upload_time).toLocaleString() - }, - upload_name: { - label: 'Name', - render: (upload) => upload.example.upload_name || '' - }, - upload_id: { - label: 'Id', - render: (upload) => <UploadId uploadId={upload.example.upload_id} /> - }, - last_processing: { - label: 'Last processed', - render: (upload) => new Date(upload.example.last_processing).toLocaleString() - }, - version: { - label: 'Processed with version', - render: (upload) => upload.example.nomad_version - }, - entries: { - label: 'Entries', - render: (upload) => upload.total - }, - published: { - label: 'Published', - align: 'center', - render: upload => <Published entry={upload.example} /> - } - } - - renderEntryActions(entry) { - const {onEdit} = this.props - return <UploadActions search upload={entry} onEdit={onEdit}/> - } - - render() { - const { classes, data, total, uploads_after, onChange, actions } = this.props - const uploads = data.uploads_grouped || {values: []} - const results = Object.keys(uploads.values).map(id => { - return { - id: id, - total: uploads.values[id].total, - example: uploads.values[id].examples[0] - } - }) - const per_page = 10 - const after = uploads.after - - let paginationText - if (uploads_after) { - paginationText = `next ${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}` - } else { - paginationText = `1-${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}` - } - - const pagination = <TableCell colSpan={1000} classes={{root: classes.scrollCell}}> - <Toolbar className={classes.scrollBar}> - <span className={classes.scrollSpacer}> </span> - <span>{paginationText}</span> - <IconButton disabled={!uploads_after} onClick={() => onChange({uploads_grouped_after: null})}> - <StartIcon /> - </IconButton> - <IconButton disabled={results.length < per_page} onClick={() => onChange({uploads_grouped_after: after})}> - <NextIcon /> - </IconButton> - </Toolbar> - </TableCell> - - return <DataTable - entityLabels={['upload', 'uploads']} - id={row => row.id} - total={total} - columns={this.columns} - selectedColumns={['upload_time', 'upload_id', 'entries', 'published']} - selectedColumnsKey="uploads" - entryActions={this.renderEntryActions} - data={results} - rows={per_page} - actions={actions} - pagination={pagination} - /> - } -} - -const UploadList = compose(withRouter, withApi(false), withStyles(UploadListUnstyled.styles))(UploadListUnstyled) - -export default UploadList diff --git a/gui/src/components/uploads/FilesBrowser.js b/gui/src/components/uploads/FilesBrowser.js index 1bd7bb1afa0d057c72ec5fb4cd441830b6da9665..4ca54b91e280602dd410efacc6a976fa024a6e01 100644 --- a/gui/src/components/uploads/FilesBrowser.js +++ b/gui/src/components/uploads/FilesBrowser.js @@ -21,7 +21,7 @@ import PropTypes from 'prop-types' import { Box, Chip, CircularProgress, Collapse, IconButton, makeStyles, Paper, Typography } from '@material-ui/core' import ExpandMoreIcon from '@material-ui/icons/ExpandMore' import ChevronRightIcon from '@material-ui/icons/ChevronRight' -import { useApi } from '../apiV1' +import { useApi } from '../api' import { useErrors } from '../errors' import PreviewIcon from '@material-ui/icons/Visibility' import FilePreview from './FilePreview' diff --git a/gui/src/components/uploads/NewUploadButton.js b/gui/src/components/uploads/NewUploadButton.js index 15207a6cb4d8a2a71dce147917da1fc447bf041f..0aca67ab77a2455d29bbb27398c75dd98c30e48b 100644 --- a/gui/src/components/uploads/NewUploadButton.js +++ b/gui/src/components/uploads/NewUploadButton.js @@ -18,7 +18,7 @@ import { Button } from '@material-ui/core' import React, { useState } from 'react' import { useHistory, useLocation } from 'react-router-dom' -import { useApi } from '../apiV1' +import { useApi } from '../api' import { useErrors } from '../errors' import { getUrl } from '../nav/Routes' diff --git a/gui/src/components/uploads/UploadPage.js b/gui/src/components/uploads/UploadPage.js index 499d01bd3aa0fe8f2b798f76dcde5008ffe33d64..2b7a4812d03f687cfed7caaa2d938531a4572f44 100644 --- a/gui/src/components/uploads/UploadPage.js +++ b/gui/src/components/uploads/UploadPage.js @@ -24,7 +24,7 @@ import Dropzone from 'react-dropzone' import UploadIcon from '@material-ui/icons/CloudUpload' import { appBase } from '../../config' import { CodeList } from '../About' -import { DoesNotExist, useApi, withLoginRequired } from '../apiV1' +import { DoesNotExist, useApi, withLoginRequired } from '../api' import { useParams } from 'react-router' import { useHistory, useLocation } from 'react-router-dom' import FilesBrower from './FilesBrowser' diff --git a/gui/src/components/uploads/UploadsPage.js b/gui/src/components/uploads/UploadsPage.js index 69fd13be0886e7183dcfc53d29120beff3bcc784..d1d992689b7d2a7638e9bcf30830631f2ddfdbcb 100644 --- a/gui/src/components/uploads/UploadsPage.js +++ b/gui/src/components/uploads/UploadsPage.js @@ -26,7 +26,7 @@ import HelpDialog from '../Help' import { CopyToClipboard } from 'react-copy-to-clipboard' import { guiBase } from '../../config' import NewUploadButton from './NewUploadButton' -import { useApi, withLoginRequired } from '../apiV1' +import { useApi, withLoginRequired } from '../api' import Page from '../Page' import { useErrors } from '../errors' import DataTable from '../DataTable' diff --git a/gui/src/testutils.js b/gui/src/testutils.js index fc9eedb55669cdc5801e0c667e0ba7331f74fb35..42a2a418b7f24706766898bdd6e01584f5e1a360 100644 --- a/gui/src/testutils.js +++ b/gui/src/testutils.js @@ -20,7 +20,6 @@ import React from 'react' import { RecoilRoot } from 'recoil' import { compose } from 'recompose' import { render } from '@testing-library/react' -import { apiContext as apiContextV0 } from './components/api' import { Router } from 'react-router-dom' import { archiveDftBulk @@ -93,22 +92,6 @@ export function withKeycloakMock(Component) { } } -/** - * HOC for injecting a mocked API implementation. - */ -export function withApiV0Mock(Component) { - const apiValue = { - api: { - archive: (upload_id, calc_id) => { - return wait(archives.get(calc_id)) - } - } - } - return <apiContextV0.Provider value={apiValue}> - {Component} - </apiContextV0.Provider> -} - /** * HOC for Router dependency injection */ @@ -133,8 +116,7 @@ export function withRecoilRoot(Component) { export function renderWithAPIRouter(component) { return render(compose( withRecoilRoot, - withRouterMock, - withApiV0Mock + withRouterMock )(component)) } diff --git a/nomad/app/v1/main.py b/nomad/app/v1/main.py index 27bc58d929f14533002ff38bd670c6dc37a40f30..83b8eccd8811d78890d86f623ea3fbde7a292d83 100644 --- a/nomad/app/v1/main.py +++ b/nomad/app/v1/main.py @@ -24,7 +24,7 @@ import traceback from nomad import config, utils from .common import root_path -from .routers import users, entries, materials, auth, datasets, uploads, suggestions +from .routers import users, entries, materials, auth, info, datasets, uploads, suggestions logger = utils.get_logger(__name__) @@ -157,6 +157,7 @@ async def unicorn_exception_handler(request: Request, e: Exception): } ) +app.include_router(info.router, prefix='/info') app.include_router(auth.router, prefix='/auth') app.include_router(materials.router, prefix='/materials') app.include_router(entries.router, prefix='/entries') diff --git a/nomad/app/v1/models.py b/nomad/app/v1/models.py index 26833ebb4e79588c67fd642cd4d0752a5cd782b3..a1ebaff79e6f0e78a9472aebb90e12a555a877af 100644 --- a/nomad/app/v1/models.py +++ b/nomad/app/v1/models.py @@ -44,8 +44,10 @@ from .utils import parameter_dependency_from_model, update_url_query_arguments User = datamodel.User.m_def.a_pydantic.model -Value = Union[StrictInt, StrictFloat, StrictBool, datetime.datetime, str] -ComparableValue = Union[StrictInt, StrictFloat, datetime.datetime, str] +# It is important that datetime.datetime comes last. Otherwise, number valued strings +# are interpreted as epoch dates by pydantic +Value = Union[StrictInt, StrictFloat, StrictBool, str, datetime.datetime] +ComparableValue = Union[StrictInt, StrictFloat, str, datetime.datetime] class HTTPExceptionModel(BaseModel): @@ -962,6 +964,11 @@ class Files(BaseModel): whole path. A re pattern will replace a given glob pattern.''')) + include_files: Optional[List[str]] = Field( + None, description=strip(''' + Optional list of file names. Only files with these names are included in the + results. This will overwrite any given glob or re pattern.''') + ) @validator('glob_pattern') def validate_glob_pattern(cls, glob_pattern): # pylint: disable=no-self-argument @@ -986,6 +993,11 @@ class Files(BaseModel): # use the compiled glob pattern as re if values.get('re_pattern') is None: values['re_pattern'] = values.get('glob_pattern') + + if values.get('include_files') is not None: + files = values['include_files'] + values['re_pattern'] = re.compile(f'({"|".join([re.escape(f) for f in files])})$') + return values @@ -1005,10 +1017,7 @@ class Bucket(BaseModel): class BucketAggregationResponse(BaseModel): data: List[Bucket] = Field( - None, description=strip(''' - The aggregation data as a dictionary. The key is a string representation of the values. - The dictionary values contain the aggregated data depending if `entries` where - requested.''')) + None, description=strip('''The aggregation data as a list.''')) class TermsAggregationResponse(BucketAggregationResponse, TermsAggregation): diff --git a/nomad/app/v1/routers/auth.py b/nomad/app/v1/routers/auth.py index a27d6271434d1ffd5e3f326dc8d47a663b7376a3..8a83e1ba00f22837dcc3870ddc18578ed0722e04 100644 --- a/nomad/app/v1/routers/auth.py +++ b/nomad/app/v1/routers/auth.py @@ -25,6 +25,8 @@ from functools import wraps from fastapi import APIRouter, Depends, Query as FastApiQuery, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from pydantic import BaseModel +import jwt +import datetime from nomad import utils, infrastructure, config, datamodel from nomad.utils import get_logger, strip @@ -44,6 +46,10 @@ class Token(BaseModel): token_type: str +class SignatureToken(BaseModel): + signature_token: str + + oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f'{root_path}/auth/token', auto_error=False) @@ -51,7 +57,8 @@ def create_user_dependency( required: bool = False, basic_auth_allowed: bool = False, bearer_token_auth_allowed: bool = True, - upload_token_auth_allowed: bool = False) -> Callable: + upload_token_auth_allowed: bool = False, + signature_token_auth_allowed: bool = False) -> Callable: ''' Creates a dependency for getting the authenticated user. The parameters define if the authentication is required or not, and which authentication methods are allowed. @@ -65,6 +72,8 @@ def create_user_dependency( user = _get_user_bearer_token_auth(kwargs.get('bearer_token')) if not user and upload_token_auth_allowed: user = _get_user_upload_token_auth(kwargs.get('token')) + if not user and signature_token_auth_allowed: + user = _get_user_signature_token_auth(kwargs.get('signature_token')) if required and not user: raise HTTPException( @@ -110,6 +119,15 @@ def create_user_dependency( None, description='Token for simplified authorization for uploading.'), kind=Parameter.KEYWORD_ONLY)) + if signature_token_auth_allowed: + new_parameters.append( + Parameter( + name='signature_token', + annotation=str, + default=FastApiQuery( + None, + description='Signature token used to sign download urls.'), + kind=Parameter.KEYWORD_ONLY)) # Create a wrapper around user_dependency, and set the signature on it @wraps(user_dependency) @@ -185,6 +203,31 @@ def _get_user_upload_token_auth(upload_token: str) -> User: return None +def _get_user_signature_token_auth(signature_token: str) -> User: + ''' + Verifies the signature token (throwing exception if illegal value provided) and returns the + corresponding user object, or None, if no upload_token provided. + ''' + if signature_token: + try: + decoded = jwt.decode(signature_token, config.services.api_secret, algorithms=['HS256']) + return datamodel.User.get(user_id=decoded['user']) + except KeyError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Token with invalid/unexpected payload.') + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Expired token.') + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid token.') + + return None + + _bad_credentials_response = status.HTTP_401_UNAUTHORIZED, { 'model': HTTPExceptionModel, 'description': strip(''' @@ -247,6 +290,25 @@ async def get_token_via_query(username: str, password: str): return {'access_token': access_token, 'token_type': 'bearer'} +@router.get( + '/signature_token', + tags=[default_tag], + summary='Get a signature token', + response_model=SignatureToken) +async def get_signature_token(user: User = Depends(create_user_dependency())): + ''' + Generates and returns a signature token for the authenticated user. Authentication + has to be provided with another method, e.g. access token. + ''' + + expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=10) + signature_token = jwt.encode( + dict(user=user.user_id, exp=expires_at), + config.services.api_secret, 'HS256').decode('utf-8') + + return {'signature_token': signature_token} + + def generate_upload_token(user): payload = uuid.UUID(user.user_id).bytes signature = hmac.new( diff --git a/nomad/app/v1/routers/datasets.py b/nomad/app/v1/routers/datasets.py index ec982ee3d08ceb713ea3aa0ed2cede9b9cbc4e2e..aecad4c433aacddd9b094c3d5d5eb402daf09d72 100644 --- a/nomad/app/v1/routers/datasets.py +++ b/nomad/app/v1/routers/datasets.py @@ -133,13 +133,18 @@ async def get_datasets( name: str = FastApiQuery(None), user_id: str = FastApiQuery(None), dataset_type: str = FastApiQuery(None), + doi: str = FastApiQuery(None), + prefix: str = FastApiQuery(None), pagination: DatasetPagination = Depends(dataset_pagination_parameters)): ''' Retrieves all datasets that match the given criteria. ''' mongodb_objects = DatasetDefinitionCls.m_def.a_mongo.objects - query_params = dict(dataset_id=dataset_id, name=name, user_id=user_id, dataset_type=dataset_type) + query_params = dict(dataset_id=dataset_id, name=name, user_id=user_id, dataset_type=dataset_type, doi=doi) + if prefix and prefix != '': + query_params.update(name=re.compile('^%s.*' % prefix, re.IGNORECASE)) # type: ignore query_params = {k: v for k, v in query_params.items() if v is not None} + mongodb_query = mongodb_objects(**query_params) order_by = pagination.order_by if pagination.order_by is not None else 'dataset_id' diff --git a/nomad/app/v1/routers/entries.py b/nomad/app/v1/routers/entries.py index ea5d726e3b842c93e801da755359dfced8fc8732..f40f805e7a6e3d83ca67f24de90439037b6f1c1b 100644 --- a/nomad/app/v1/routers/entries.py +++ b/nomad/app/v1/routers/entries.py @@ -16,24 +16,30 @@ # limitations under the License. # -from typing import Optional, Union, Dict, Iterator, Any, List +from datetime import datetime + +from typing import Optional, Set, Union, Dict, Iterator, Any, List from fastapi import ( APIRouter, Depends, Path, status, HTTPException, Request, Query as QueryParameter, Body) from fastapi.responses import StreamingResponse from fastapi.exceptions import RequestValidationError -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator import os.path import io import json import orjson +from pydantic.main import create_model +from starlette.responses import Response -from nomad import files, config, utils +from nomad import files, config, utils, metainfo, processing as proc +from nomad import datamodel +from nomad.datamodel import EditableUserMetadata from nomad.files import StreamedFile, create_zipstream from nomad.utils import strip from nomad.archive import RequiredReader, RequiredValidationError, ArchiveQueryError from nomad.archive import ArchiveQueryError -from nomad.search import AuthenticationRequiredError, SearchError +from nomad.search import AuthenticationRequiredError, SearchError, update_metadata as es_update_metadata from nomad.search.v1 import search, QueryValidationError from nomad.metainfo.elasticsearch_extension import entry_type @@ -42,7 +48,7 @@ from ..utils import ( create_download_stream_zipped, create_download_stream_raw_file, DownloadItem, create_responses) from ..models import ( - PaginationResponse, MetadataPagination, WithQuery, WithQueryAndPagination, MetadataRequired, + Aggregation, Pagination, PaginationResponse, MetadataPagination, TermsAggregation, WithQuery, WithQueryAndPagination, MetadataRequired, MetadataResponse, Metadata, Files, Query, User, Owner, QueryParameters, metadata_required_parameters, files_parameters, metadata_pagination_parameters, HTTPExceptionModel) @@ -228,6 +234,37 @@ class EntryMetadataResponse(BaseModel): None, description=strip('''The entry metadata as dictionary.''')) +class EntryMetadataEditActionField(BaseModel): + value: str = Field(None, description='The value/values that is set as a string.') + success: Optional[bool] = Field(None, description='If this can/could be done. Only in API response.') + message: Optional[str] = Field(None, descriptin='A message that details the action result. Only in API response.') + + +EntryMetadataEditActions = create_model('EntryMetadataEditActions', **{ # type: ignore + quantity.name: ( + Optional[EntryMetadataEditActionField] + if quantity.is_scalar else Optional[List[EntryMetadataEditActionField]], None) + for quantity in EditableUserMetadata.m_def.definitions + if isinstance(quantity, metainfo.Quantity) +}) + + +class EntryMetadataEdit(WithQuery): + verify: Optional[bool] = Field(False, description='If true, no action is performed.') + actions: EntryMetadataEditActions = Field( # type: ignore + None, + description='Each action specifies a single value (even for multi valued quantities).') + + @validator('owner') + def validate_query(cls, owner): # pylint: disable=no-self-argument + return Owner.user + + +class EntryMetadataEditResponse(EntryMetadataEdit): + success: bool = Field(None, description='If the overall edit can/could be done. Only in API response.') + message: str = Field(None, description='A message that details the overall edit result. Only in API response.') + + _bad_owner_response = status.HTTP_401_UNAUTHORIZED, { 'model': HTTPExceptionModel, 'description': strip(''' @@ -258,13 +295,19 @@ _raw_download_file_response = 200, { on the file contents. ''')} -_archive_download_response = 200, { +_archives_download_response = 200, { 'content': {'application/zip': {}}, 'description': strip(''' A zip file with the requested archive files. The file is streamed. The content length is not known in advance. ''')} +_archive_download_response = 200, { + 'content': {'application/json': {}}, + 'description': strip(''' + A json body with the requested archive. + ''')} + _bad_archive_required_response = status.HTTP_400_BAD_REQUEST, { 'model': HTTPExceptionModel, @@ -272,6 +315,12 @@ _bad_archive_required_response = status.HTTP_400_BAD_REQUEST, { The given required specification could not be understood.''')} +_bad_metadata_edit_response = status.HTTP_400_BAD_REQUEST, { + 'model': HTTPExceptionModel, + 'description': strip(''' + The given edit actions cannot be performed by you on the given query.''')} + + def perform_search(*args, **kwargs): try: search_response = search(*args, **kwargs) @@ -591,7 +640,7 @@ async def post_entries_raw_download_query( async def get_entries_raw_download( with_query: WithQuery = Depends(query_parameters), files: Files = Depends(files_parameters), - user: User = Depends(create_user_dependency())): + user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))): return _answer_entries_raw_download_request( owner=with_query.owner, query=with_query.query, files=files, user=user) @@ -792,7 +841,7 @@ _entries_archive_download_docstring = strip(''' description=_entries_archive_download_docstring, response_class=StreamingResponse, responses=create_responses( - _archive_download_response, _bad_owner_response, _bad_archive_required_response)) + _archives_download_response, _bad_owner_response, _bad_archive_required_response)) async def post_entries_archive_download_query( data: EntriesArchiveDownload, user: User = Depends(create_user_dependency())): @@ -807,11 +856,11 @@ async def post_entries_archive_download_query( description=_entries_archive_download_docstring, response_class=StreamingResponse, responses=create_responses( - _archive_download_response, _bad_owner_response, _bad_archive_required_response)) + _archives_download_response, _bad_owner_response, _bad_archive_required_response)) async def get_entries_archive_download( with_query: WithQuery = Depends(query_parameters), files: Files = Depends(files_parameters), - user: User = Depends(create_user_dependency())): + user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))): return _answer_entries_archive_download_request( owner=with_query.owner, query=with_query.query, files=files, user=user) @@ -857,7 +906,6 @@ async def get_entry_metadata( response_model_exclude_none=True) async def get_entry_raw( entry_id: str = Path(..., description='The unique entry id of the entry to retrieve raw data from.'), - files: Files = Depends(files_parameters), user: User = Depends(create_user_dependency())): ''' Returns the file metadata for all input and output files (including auxiliary files) @@ -890,7 +938,7 @@ async def get_entry_raw( async def get_entry_raw_download( entry_id: str = Path(..., description='The unique entry id of the entry to retrieve raw data from.'), files: Files = Depends(files_parameters), - user: User = Depends(create_user_dependency())): + user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))): ''' Streams a .zip file with the raw files from the requested entry. ''' @@ -928,7 +976,7 @@ async def get_entry_raw_download_file( decompress: Optional[bool] = QueryParameter( False, description=strip(''' Attempt to decompress the contents, if the file is .gz or .xz.''')), - user: User = Depends(create_user_dependency())): + user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))): ''' Streams the contents of an individual file from the requested entry. ''' @@ -1021,6 +1069,21 @@ async def get_entry_archive( return _answer_entry_archive_request(entry_id=entry_id, required='*', user=user) +@router.get( + '/{entry_id}/archive/download', + tags=[archive_tag], + summary='Get the archive for an entry by its id as plain archive json', + responses=create_responses(_bad_id_response, _archive_download_response)) +async def get_entry_archive_download( + entry_id: str = Path(..., description='The unique entry id of the entry to retrieve raw data from.'), + user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))): + ''' + Returns the full archive for the given `entry_id`. + ''' + response = _answer_entry_archive_request(entry_id=entry_id, required='*', user=user) + return response['data']['archive'] + + @router.post( '/{entry_id}/archive/query', tags=[archive_tag], @@ -1038,3 +1101,225 @@ async def post_entry_archive_query( in the body. ''' return _answer_entry_archive_request(entry_id=entry_id, required=data.required, user=user) + + +def edit(query: Query, user: User, mongo_update: Dict[str, Any] = None, re_index=True) -> List[str]: + # get all calculations that have to change + entry_ids: List[str] = [] + upload_ids: Set[str] = set() + with utils.timer(logger, 'edit query executed'): + all_entries = _do_exaustive_search( + owner=Owner.user, query=query, include=['entry_id', 'upload_id'], user=user) + + for entry in all_entries: + entry_ids.append(entry['entry_id']) + upload_ids.add(entry['upload_id']) + + # perform the update on the mongo db + with utils.timer(logger, 'edit mongo update executed', size=len(entry_ids)): + if mongo_update is not None: + n_updated = proc.Calc.objects(calc_id__in=entry_ids).update(multi=True, **mongo_update) + if n_updated != len(entry_ids): + logger.error('edit repo did not update all entries', payload=mongo_update) + + # re-index the affected entries in elastic search + with utils.timer(logger, 'edit elastic update executed', size=len(entry_ids)): + if re_index: + updated_metadata: List[datamodel.EntryMetadata] = [] + for calc in proc.Calc.objects(calc_id__in=entry_ids): + updated_metadata.append( + datamodel.EntryMetadata(calc_id=calc.calc_id, **calc.metadata)) + + failed = es_update_metadata(updated_metadata, update_materials=True, refresh=True) + + if failed > 0: + logger.error( + 'edit repo with failed elastic updates', + payload=mongo_update, nfailed=failed) + + return list(upload_ids) + + +def get_quantity_values(quantity, **kwargs): + ''' Get all the uploader from the query, to check coauthers and shared_with for uploaders. ''' + response = perform_search( + **kwargs, + aggregations=dict(agg=Aggregation(terms=TermsAggregation(quantity=quantity))), + pagination=Pagination(page_size=0)) + terms = response.aggregations['agg'].terms # pylint: disable=no-member + return [bucket.value for bucket in terms.data] + + +_editable_quantities = { + quantity.name: quantity for quantity in EditableUserMetadata.m_def.definitions} + + +@router.post( + '/edit', + tags=[archive_tag], + summary='Edit the user metadata of a set of entries', + response_model=EntryMetadataEditResponse, + response_model_exclude_unset=True, + response_model_exclude_none=True, + responses=create_responses(_bad_metadata_edit_response)) +async def post_entry_metadata_edit( + response: Response, + data: EntryMetadataEdit, + user: User = Depends(create_user_dependency())): + + ''' + Performs or validates edit actions on a set of entries that match a given query. + ''' + + # checking the edit actions and preparing a mongo update on the fly + query = data.query + data = EntryMetadataEditResponse(**data.dict()) + data.query = query # to dict from dict does not work with the op aliases in queries + actions = data.actions + verify = data.verify + data.success = True + mongo_update = {} + uploader_ids = None + has_error = False + removed_datasets = None + + with utils.timer(logger, 'edit verified'): + for action_quantity_name in actions.dict(): # type: ignore + quantity_actions = getattr(actions, action_quantity_name, None) + if quantity_actions is None: + continue + + quantity = _editable_quantities.get(action_quantity_name) + if quantity is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Unknown quantity %s' % action_quantity_name) + + # TODO this does not work. Because the quantities are not in EditableUserMetadata + # they are also not in the model and ignored by fastapi. This probably + # also did not work in the old API. + if action_quantity_name in ['uploader', 'upload_time']: + if not user.is_admin(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Only the admin user can set %s' % quantity.name) + + if isinstance(quantity_actions, list) == quantity.is_scalar: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Wrong shape for quantity %s' % action_quantity_name) + + if not isinstance(quantity_actions, list): + quantity_actions = [quantity_actions] + + verify_reference = None + if isinstance(quantity.type, metainfo.Reference): + verify_reference = quantity.type.target_section_def.section_cls + + mongo_key = 'metadata__%s' % quantity.name + has_error = False + for action in quantity_actions: + action.success = True + action.message = None + action_value = action.value + action_value = action_value if action_value is None else action_value.strip() + + if action_quantity_name == 'with_embargo': + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Updating the embargo flag on entry level is no longer allowed.') + + if action_value is None: + mongo_value = None + + elif action_value == '': + mongo_value = None + + elif verify_reference in [datamodel.User, datamodel.Author]: + try: + mongo_value = datamodel.User.get(user_id=action_value).user_id + except KeyError: + action.success = False + has_error = True + action.message = 'User does not exist' + continue + + if uploader_ids is None: + uploader_ids = get_quantity_values( + quantity='uploader.user_id', owner=Owner.user, query=data.query, user_id=user.user_id) + if action_value in uploader_ids: + action.success = False + has_error = True + action.message = 'This user is already an uploader of one entry in the query' + continue + + elif verify_reference == datamodel.Dataset: + try: + mongo_value = datamodel.Dataset.m_def.a_mongo.get( + user_id=user.user_id, name=action_value).dataset_id + except KeyError: + action.message = 'Dataset does not exist and will be created' + mongo_value = None + if not verify: + dataset = datamodel.Dataset( + dataset_id=utils.create_uuid(), user_id=user.user_id, + name=action_value, created=datetime.utcnow()) + dataset.a_mongo.create() + mongo_value = dataset.dataset_id + + else: + mongo_value = action_value + + if len(quantity.shape) == 0: + mongo_update[mongo_key] = mongo_value + else: + mongo_values = mongo_update.setdefault(mongo_key, []) + if mongo_value is not None: + if mongo_value in mongo_values: + action.success = False + has_error = True + action.message = 'Duplicate values are not allowed' + continue + mongo_values.append(mongo_value) + + if len(quantity_actions) == 0 and len(quantity.shape) > 0: + mongo_update[mongo_key] = [] + + if action_quantity_name == 'datasets': + # check if datasets edit is allowed and if datasets have to be removed + old_datasets = get_quantity_values( + quantity='datasets.dataset_id', owner=Owner.user, query=data.query, user_id=user.user_id) + + removed_datasets = [] + for dataset_id in old_datasets: + if dataset_id not in mongo_update.get(mongo_key, []): + removed_datasets.append(dataset_id) + + doi_ds = datamodel.Dataset.m_def.a_mongo.objects( + dataset_id__in=removed_datasets, doi__ne=None).first() + if doi_ds is not None: + data.success = False + data.message = (data.message if data.message else '') + ( + 'Edit would remove entries from a dataset with DOI (%s) ' % doi_ds.name) + has_error = True + + # stop here, if client just wants to verify its actions + if verify: + return data + + # stop if the action were not ok + if has_error: + response.status_code = status.HTTP_400_BAD_REQUEST + return data + + # perform the change + mongo_update['metadata__last_edit'] = datetime.utcnow() + edit(data.query, user, mongo_update, True) + + # remove potentially empty old datasets + if removed_datasets is not None: + for dataset in removed_datasets: + if proc.Calc.objects(metadata__datasets=dataset).first() is None: + datamodel.Dataset.m_def.a_mongo.objects(dataset_id=dataset).delete() + + return data diff --git a/nomad/app/v1/routers/info.py b/nomad/app/v1/routers/info.py new file mode 100644 index 0000000000000000000000000000000000000000..5c27f8c4f301c8ec546923be3fb96b4d36d10afb --- /dev/null +++ b/nomad/app/v1/routers/info.py @@ -0,0 +1,158 @@ +# +# 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. +# + +''' +API endpoint that deliver backend configuration details. +''' + +from typing import Dict, Any, List, Optional +from datetime import datetime +from fastapi.routing import APIRouter +from pydantic.fields import Field +from pydantic.main import BaseModel + +from nomad import config, normalizing, datamodel, gitinfo +from nomad.utils import strip +from nomad.search import v0 as search +from nomad.parsing import parsers, MatchingParser + + +router = APIRouter() +default_tag = 'info' + + +class MetainfoModel(BaseModel): + all_package: str = Field(None, description=strip(''' + Name of the metainfo package that references all available packages, i.e. + the complete metainfo.''')) + + root_section: str = Field(None, description=strip(''' + Name of the topmost section, e.g. section run for computational material science + data.''')) + + +class DomainModel(BaseModel): + name: str + metainfo: MetainfoModel + + +class GitInfoModel(BaseModel): + ref: str + version: str + commit: str + log: str + + +class StatisticsModel(BaseModel): + n_entries: int = Field(None, description='Number of entries in NOMAD') + n_uploads: int = Field(None, description='Number of uploads in NOMAD') + n_quantities: int = Field(None, description='Accumulated number of quantities over all entries in the Archive') + n_calculations: int = Field(None, description='Accumulated number of calculations, e.g. total energy calculations in the Archive') + n_materials: int = Field(None, description='Number of materials in NOMAD') + # TODO raw_file_size, archive_file_size + + +class CodeInfoModel(BaseModel): + code_name: Optional[str] = Field(None, description='Name of the code or input format') + code_homepage: Optional[str] = Field(None, description='Homepage of the code or input format') + + +class InfoModel(BaseModel): + parsers: List[str] + metainfo_packages: List[str] + codes: List[CodeInfoModel] + normalizers: List[str] + domains: List[DomainModel] + statistics: StatisticsModel = Field(None, description='General NOMAD statistics') + search_quantities: dict + version: str + release: str + git: GitInfoModel + oasis: bool + + +_statistics: Dict[str, Any] = None + + +def statistics(): + global _statistics + if _statistics is None or datetime.now().timestamp() - _statistics.get('timestamp', 0) > 3600 * 24: + _statistics = dict(timestamp=datetime.now().timestamp()) + _statistics.update( + **search.SearchRequest().global_statistics().execute()['global_statistics']) + + return _statistics + + +@router.get( + '', + tags=[default_tag], + summary='Get information about the nomad backend and its configuration', + response_model_exclude_unset=True, + response_model_exclude_none=True, + response_model=InfoModel) +async def get_info(): + ''' Return information about the nomad backend and its configuration. ''' + codes_dict = {} + for parser in parsers.parser_dict.values(): + if isinstance(parser, MatchingParser) and parser.domain == 'dft': + code_name = parser.code_name + if code_name in codes_dict: + continue + codes_dict[code_name] = dict(code_name=code_name, code_homepage=parser.code_homepage) + codes = sorted(list(codes_dict.values()), key=lambda code_info: code_info['code_name'].lower()) + + return { + 'parsers': [ + key[key.index('/') + 1:] + for key in parsers.parser_dict.keys()], + 'metainfo_packages': ['general', 'general.experimental', 'common', 'public'] + sorted([ + key[key.index('/') + 1:] + for key in parsers.parser_dict.keys()]), + 'codes': codes, + 'normalizers': [normalizer.__name__ for normalizer in normalizing.normalizers], + 'statistics': statistics(), + 'domains': [ + { + 'name': domain_name, + 'metainfo': { + 'all_package': domain['metainfo_all_package'], + 'root_section': domain['root_section'] + } + } + for domain_name, domain in datamodel.domains.items() + ], + 'search_quantities': { + s.qualified_name: { + 'name': s.qualified_name, + 'description': s.description, + 'many': s.many + } + for s in search.search_quantities.values() + if 'optimade' not in s.qualified_name + }, + 'version': config.meta.version, + 'release': config.meta.release, + 'git': { + 'ref': gitinfo.ref, + 'version': gitinfo.version, + 'commit': gitinfo.commit, + 'log': gitinfo.log + }, + 'oasis': config.keycloak.oasis + } diff --git a/nomad/app/v1/routers/users.py b/nomad/app/v1/routers/users.py index 39e0ab832ff54762f00f27c31b37c73447d65e4a..87ae5b8302d74e9b8cc8685c0109f6734dc2e59e 100644 --- a/nomad/app/v1/routers/users.py +++ b/nomad/app/v1/routers/users.py @@ -16,8 +16,11 @@ # limitations under the License. # -from fastapi import Depends, APIRouter, status +from typing import List +from fastapi import Depends, APIRouter, status, HTTPException +from pydantic.main import BaseModel +from nomad import infrastructure, config, datamodel from nomad.utils import strip from .auth import create_user_dependency @@ -34,6 +37,15 @@ _authentication_required_response = status.HTTP_401_UNAUTHORIZED, { Unauthorized. The operation requires authorization, but no or bad authentication credentials are given.''')} +_bad_invite_response = status.HTTP_400_BAD_REQUEST, { + 'model': HTTPExceptionModel, + 'description': strip(''' + The invite is invalid.''')} + + +class Users(BaseModel): + data: List[User] + @router.get( '/me', @@ -44,3 +56,60 @@ _authentication_required_response = status.HTTP_401_UNAUTHORIZED, { response_model=User) async def read_users_me(current_user: User = Depends(create_user_dependency(required=True))): return current_user + + +@router.get( + '', + tags=[default_tag], + summary='Get existing users', + description='Get existing users witht the given prefix', + response_model_exclude_unset=True, + response_model_exclude_none=True, + response_model=Users) +async def get_users(prefix: str): + users = [] + for user in infrastructure.keycloak.search_user(prefix): + user_dict = user.m_to_dict(include_derived=True) + user_dict['email'] = None + users.append(user_dict) + return dict(data=users) + + +@router.put( + '/invite', + tags=[default_tag], + summary='Invite a new user', + responses=create_responses(_authentication_required_response, _bad_invite_response), + response_model=User) +async def invite_user(user: User, current_user: User = Depends(create_user_dependency(required=True))): + if config.keycloak.oasis: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='User invide does not work this NOMAD OASIS.') + + json_data = user.dict() + try: + user = datamodel.User.m_from_dict(json_data) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid user data: %s' % str(e)) + + if user.email is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid user data: email is required') + + try: + error = infrastructure.keycloak.add_user(user, invite=True) + except KeyError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid user data: %s' % str(e)) + + if error is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Could not invite user: %s' % str(error)) + + return datamodel.User.get(username=user.username), 200 diff --git a/nomad/datamodel/datamodel.py b/nomad/datamodel/datamodel.py index 50977aec8aeaa28353edbcbb8060d6bd6f4a8168..359c203091efb8e11bef680c6006437a6a805bc5 100644 --- a/nomad/datamodel/datamodel.py +++ b/nomad/datamodel/datamodel.py @@ -29,7 +29,7 @@ from nomad.metainfo.elastic_extension import ElasticDocument from nomad.metainfo.mongoengine_extension import Mongo, MongoDocument from nomad.datamodel.metainfo.common import FastAccess from nomad.metainfo.pydantic_extension import PydanticModel -from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_entry_type +from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_entry_type, entry_type from .dft import DFTMetadata from .ems import EMSMetadata @@ -66,6 +66,19 @@ def PathSearch(): field='path', _es_field='')] +quantity_analyzer = analyzer( + 'quantity_analyzer', + tokenizer=tokenizer('quantity_tokenizer', 'pattern', pattern='.')) + + +def QuantitySearch(): + return [ + Elasticsearch(_es_field='keyword'), + Elasticsearch( + mapping=dict(type='text', analyzer=path_analyzer.to_dict()), + field='path', _es_field='')] + + class Author(metainfo.MSection): ''' A person that is author of data in NOMAD or references by NOMAD. ''' name = metainfo.Quantity( @@ -440,7 +453,8 @@ class EntryMetadata(metainfo.MSection): The unique, sequentially enumerated, integer PID that was used in the legacy NOMAD CoE. It allows to resolve URLs of the old NOMAD CoE Repository.''', categories=[MongoMetadata], - a_search=Search(many_or='append')) + a_search=Search(many_or='append'), + a_elasticsearch=Elasticsearch(entry_type)) raw_id = metainfo.Quantity( type=str, @@ -651,6 +665,14 @@ class EntryMetadata(metainfo.MSection): description='The number of atoms in the entry\'s material', a_search=Search()) + n_quantities = metainfo.Quantity( + type=int, default=0, description='Number of metainfo quantities parsed from the entry.') + + quantities = metainfo.Quantity( + type=str, shape=['0..*'], + description='All quantities that are used by this entry.', + a_elasticsearch=QuantitySearch()) + ems = metainfo.SubSection(sub_section=EMSMetadata, a_search=Search()) dft = metainfo.SubSection(sub_section=DFTMetadata, a_search=Search(), categories=[FastAccess]) qcms = metainfo.SubSection(sub_section=QCMSMetadata, a_search=Search()) @@ -674,6 +696,36 @@ class EntryMetadata(metainfo.MSection): domain_section.apply_domain_metadata(archive) + quantities = set() + n_quantities = 0 + + section_paths = {} + + def get_section_path(section): + section_path = section_paths.get(section) + if section_path is None: + parent = section.m_parent + if parent: + parent_path = get_section_path(parent) + if parent_path == '': + section_path = section.m_parent_sub_section.name + else: + section_path = f'{parent_path}.{section.m_parent_sub_section.name}' + else: + section_path = '' + section_paths[section] = section_path + quantities.add(section_path) + + return section_path + + for section, property_def, _ in archive.m_traverse(): + quantity_path = f'{get_section_path(section)}.{property_def.name}' + quantities.add(quantity_path) + n_quantities += 1 + + self.quantities = list(quantities) + self.n_quantities = n_quantities + class EntryArchive(metainfo.MSection): entry_id = metainfo.Quantity( diff --git a/nomad/datamodel/results.py b/nomad/datamodel/results.py index 1cfa41037a4e94a06278d1725285d0fd3895274c..ffb7ba140215782b4b3a45526a278fe2a03466d5 100644 --- a/nomad/datamodel/results.py +++ b/nomad/datamodel/results.py @@ -16,13 +16,13 @@ # limitations under the License. # -from nomad.datamodel.metainfo.common_experimental import Spectrum import numpy as np from elasticsearch_dsl import Text from ase.data import chemical_symbols from nomad import config +from nomad.datamodel.metainfo.common_experimental import Spectrum from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_type, material_entry_type from nomad.metainfo import ( diff --git a/tests/app/v1/conftest.py b/tests/app/v1/conftest.py index a0287c50c17a830567c6293e72e1878a4c22e53d..a2202c3146065cf679b22850576736973c6ca993 100644 --- a/tests/app/v1/conftest.py +++ b/tests/app/v1/conftest.py @@ -104,13 +104,17 @@ def example_data(elastic_module, raw_files_module, mongo_module, test_user, othe entry_id = 'id_%02d' % i material_id = 'id_%02d' % (int(math.floor(i / 4)) + 1) mainfile = 'test_content/subdir/test_entry_%02d/mainfile.json' % i + kwargs = {} if i == 11: mainfile = 'test_content/subdir/test_entry_10/mainfile_11.json' + if i == 1: + kwargs['pid'] = '123' data.create_entry( upload_id='id_published', calc_id=entry_id, material_id=material_id, - mainfile=mainfile) + mainfile=mainfile, + **kwargs) if i == 1: archive = data.archives[entry_id] diff --git a/tests/app/v1/routers/common.py b/tests/app/v1/routers/common.py index 1e54603f93d8833e31d84093419d4e0b76dee0f9..2df46931d70aa9314493695f0670ffde9d09022d 100644 --- a/tests/app/v1/routers/common.py +++ b/tests/app/v1/routers/common.py @@ -34,6 +34,7 @@ def post_query_test_parameters( program_name = f'{entry_prefix}results.method.simulation.program_name' method = f'{entry_prefix}results.method' properties = f'{entry_prefix}results.properties' + upload_time = f'{entry_prefix}upload_time' return [ pytest.param({}, 200, total, id='empty'), @@ -64,7 +65,10 @@ def post_query_test_parameters( pytest.param({method: {'simulation.program_name': 'VASP'}}, 200, total, id='inner-object'), pytest.param({f'{properties}.electronic.dos_electronic.spin_polarized': True}, 200, 1, id='nested-implicit'), pytest.param({f'{properties}.electronic.dos_electronic': {'spin_polarized': True}}, 200, 1, id='nested-explicit'), - pytest.param({properties: {'electronic.dos_electronic': {'spin_polarized': True}}}, 200, 1, id='nested-explicit-explicit') + pytest.param({properties: {'electronic.dos_electronic': {'spin_polarized': True}}}, 200, 1, id='nested-explicit-explicit'), + pytest.param({f'{upload_time}:gt': '1970-01-01'}, 200, total, id='date-1'), + pytest.param({f'{upload_time}:lt': '2099-01-01'}, 200, total, id='date-2'), + pytest.param({f'{upload_time}:gt': '2099-01-01'}, 200, 0, id='date-3') ] @@ -73,6 +77,7 @@ def get_query_test_parameters( elements = f'{material_prefix}elements' n_elements = f'{material_prefix}n_elements' + upload_time = f'{entry_prefix}upload_time' return [ pytest.param({}, 200, total, id='empty'), @@ -96,7 +101,8 @@ def get_query_test_parameters( pytest.param({'q': f'{n_elements}__gt__2'}, 200, 0, id='q-gt'), pytest.param({'q': f'{entry_prefix}upload_time__gt__2014-01-01'}, 200, total, id='datetime'), pytest.param({'q': [elements + '__all__H', elements + '__all__O']}, 200, total, id='q-all'), - pytest.param({'q': [elements + '__all__H', elements + '__all__X']}, 200, 0, id='q-all') + pytest.param({'q': [elements + '__all__H', elements + '__all__X']}, 200, 0, id='q-all'), + pytest.param({'q': f'{upload_time}__gt__1970-01-01'}, 200, total, id='date') ] @@ -454,7 +460,6 @@ def assert_aggregations( value = bucket['value'] if agg_type == 'date_histogram': assert re.match(r'\d{4}\-\d{2}\-\d{2}', value) elif agg_type == 'histogram': assert isinstance(value, (float, int)) - else: assert isinstance(value, str) for metric in agg.get('metrics', []): assert metric in bucket['metrics'] diff --git a/tests/app/v1/routers/test_auth.py b/tests/app/v1/routers/test_auth.py index 49b434165eed1e9afec538f7860e4e59ec5fe23e..b82a2f73261eeba55e678bdfe8aeb8fa549a000e 100644 --- a/tests/app/v1/routers/test_auth.py +++ b/tests/app/v1/routers/test_auth.py @@ -40,3 +40,9 @@ def test_get_token(client, test_user, http_method): @pytest.mark.parametrize('http_method', ['post', 'get']) def test_get_token_bad_credentials(client, http_method): perform_get_token_test(client, http_method, 401, 'bad', 'credentials') + + +def test_get_signature_token(client, test_user_auth): + response = client.get('auth/signature_token', headers=test_user_auth) + assert response.status_code == 200 + assert response.json().get('signature_token') is not None diff --git a/tests/app/v1/routers/test_datasets.py b/tests/app/v1/routers/test_datasets.py index 672cc4e80ac4e6f8cbf6c5956abdd0835c099be8..cc501e5cca126464639853bb0d87889505e5874a 100644 --- a/tests/app/v1/routers/test_datasets.py +++ b/tests/app/v1/routers/test_datasets.py @@ -114,7 +114,10 @@ def assert_pagination(pagination): def assert_dataset(dataset, query: Query = None, entries: List[str] = None, n_entries: int = -1, **kwargs): for key, value in kwargs.items(): - assert dataset[key] == value + if key == 'prefix': + assert dataset['name'].startswith(value) + else: + assert dataset[key] == value dataset_id = dataset['dataset_id'] @@ -166,7 +169,9 @@ def assert_dataset_deleted(dataset_id): pytest.param({}, 4, 200, id='empty'), pytest.param({'dataset_id': 'dataset_1'}, 1, 200, id='id'), pytest.param({'name': 'test dataset 1'}, 1, 200, id='name'), + pytest.param({'prefix': 'test dat'}, 2, 200, id='prefix'), pytest.param({'dataset_type': 'foreign'}, 2, 200, id='type'), + pytest.param({'doi': 'test_doi'}, 1, 200, id='doi'), pytest.param({'dataset_id': 'DOESNOTEXIST'}, 0, 200, id='id-not-exists') ]) def test_datasets(client, data, query, size, status_code): diff --git a/tests/app/v1/routers/test_entries.py b/tests/app/v1/routers/test_entries.py index bb8706ac7d9988861420882071a76dedf6c3a8a7..afd0f82457357cc395a8a492f06dc891bede6668 100644 --- a/tests/app/v1/routers/test_entries.py +++ b/tests/app/v1/routers/test_entries.py @@ -427,7 +427,9 @@ def test_entries_raw(client, data, query, files, total, files_per_entry, status_ pytest.param({}, {'re_pattern': 'test_entry_02/.*\\.json'}, 1, 1, 200, id='re-filter-entries-and-files'), pytest.param({}, {'glob_pattern': '*.json', 're_pattern': '.*\\.aux'}, 23, 4, 200, id='re-overwrites-glob'), pytest.param({}, {'re_pattern': '**'}, -1, -1, 422, id='bad-re-pattern'), - pytest.param({}, {'compress': True}, 23, 5, 200, id='compress') + pytest.param({}, {'compress': True}, 23, 5, 200, id='compress'), + pytest.param({}, {'include_files': ['1.aux']}, 23, 1, 200, id='file'), + pytest.param({}, {'include_files': ['1.aux', '2.aux']}, 23, 2, 200, id='files') ]) @pytest.mark.parametrize('http_method', ['post', 'get']) def test_entries_download_raw(client, data, query, files, total, files_per_entry, status_code, http_method): @@ -463,7 +465,10 @@ def test_entry_raw(client, data, entry_id, files_per_entry, status_code): pytest.param('id_01', {'glob_pattern': '*.json'}, 1, 200, id='glob'), pytest.param('id_01', {'re_pattern': '[a-z]*\\.aux'}, 4, 200, id='re'), pytest.param('id_01', {'re_pattern': '**'}, -1, 422, id='bad-re-pattern'), - pytest.param('id_01', {'compress': True}, 5, 200, id='compress')]) + pytest.param('id_01', {'compress': True}, 5, 200, id='compress'), + pytest.param('id_01', {'include_files': ['1.aux']}, 1, 200, id='file'), + pytest.param('id_01', {'include_files': ['1.aux', '2.aux']}, 2, 200, id='files') +]) def test_entry_raw_download(client, data, entry_id, files, files_per_entry, status_code): response = client.get('entries/%s/raw/download?%s' % (entry_id, urlencode(files, doseq=True))) assert_response(response, status_code) @@ -601,6 +606,19 @@ def test_entry_archive(client, data, entry_id, status_code): assert_archive_response(response.json()) +@pytest.mark.parametrize('entry_id, status_code', [ + pytest.param('id_01', 200, id='id'), + pytest.param('id_02', 404, id='404-not-visible'), + pytest.param('doesnotexist', 404, id='404-does-not-exist')]) +def test_entry_archive_download(client, data, entry_id, status_code): + response = client.get('entries/%s/archive/download' % entry_id) + assert_response(response, status_code) + if status_code == 200: + archive = response.json() + assert 'metadata' in archive + assert 'run' in archive + + @pytest.mark.parametrize('entry_id, required, status_code', [ pytest.param('id_01', '*', 200, id='full'), pytest.param('id_02', '*', 404, id='404'), @@ -625,7 +643,9 @@ n_elements = 'results.material.n_elements' @pytest.mark.parametrize('query, status_code, total', post_query_test_parameters( - 'entry_id', total=23, material_prefix='results.material.', entry_prefix='')) + 'entry_id', total=23, material_prefix='results.material.', entry_prefix='') + [ + pytest.param({'pid': '123'}, 200, 1, id='number-valued-string') +]) @pytest.mark.parametrize('test_method', [ pytest.param(perform_entries_metadata_test, id='metadata'), pytest.param(perform_entries_raw_download_test, id='raw-download'), diff --git a/tests/app/v1/routers/test_entries_edit.py b/tests/app/v1/routers/test_entries_edit.py new file mode 100644 index 0000000000000000000000000000000000000000..032b75c5385dad7973f84a09e57837eb8db7ff17 --- /dev/null +++ b/tests/app/v1/routers/test_entries_edit.py @@ -0,0 +1,301 @@ +# +# 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 pytest + +from nomad import utils +from nomad.search.v1 import search +from nomad.datamodel import Dataset +from nomad import processing as proc + +from tests.utils import ExampleData + + +logger = utils.get_logger(__name__) + + +class TestEditRepo(): + + def query(self, *uploads): + return {'upload_id:any': uploads} + + @pytest.fixture(autouse=True) + def set_api(self, client, elastic, mongo): + self.api = client + + @pytest.fixture(autouse=True) + def example_datasets(self, test_user, other_test_user, mongo): + self.example_dataset = Dataset( + dataset_id='example_ds', name='example_ds', user_id=test_user.user_id) + self.example_dataset.a_mongo.create() + + self.other_example_dataset = Dataset( + dataset_id='other_example_ds', name='other_example_ds', + user_id=other_test_user.user_id) + self.other_example_dataset.a_mongo.create() + + @pytest.fixture(autouse=True) + def example_data(self, test_user, other_test_user, raw_files, elastic, mongo): + # TODO + example_data = ExampleData() + + example_data.create_entry( + upload_id='upload_1', uploader=test_user, published=True, with_embargo=False) + example_data.create_entry( + upload_id='upload_2', uploader=test_user, published=True, with_embargo=True) + example_data.create_entry( + upload_id='upload_2', uploader=test_user, published=True, with_embargo=True) + example_data.create_entry( + upload_id='upload_3', uploader=other_test_user, published=True, with_embargo=False) + + example_data.save() + + @pytest.fixture(autouse=True) + def auth(self, test_user_auth): + self.test_user_auth = test_user_auth + + def perform_edit(self, query=None, verify=False, **kwargs): + actions = {} + for key, value in kwargs.items(): + if isinstance(value, list): + actions[key] = [dict(value=i) for i in value] + else: + actions[key] = dict(value=value) + + data = dict(actions=actions) + if query is not None: + data.update(query=query) + if verify: + data.update(verify=verify) + + return self.api.post('entries/edit', headers=self.test_user_auth, json=data) + + def assert_edit(self, rv, quantity: str, success: bool, message: bool, status_code: int = 200): + data = rv.json() + assert rv.status_code == status_code, data + actions = data.get('actions') + assert actions is not None + assert [quantity] == list(actions.keys()) + quantity_actions = actions[quantity] + if not isinstance(quantity_actions, list): + quantity_actions = [quantity_actions] + has_failure = False + has_message = False + for action in quantity_actions: + has_failure = has_failure or not action['success'] + has_message = has_message or ('message' in action) + assert not has_failure == success + assert has_message == message + + def mongo(self, *args, edited: bool = True, **kwargs): + for calc_id in args: + calc = proc.Calc.objects(calc_id='test_entry_id_%d' % calc_id).first() + assert calc is not None + metadata = calc.metadata + if edited: + assert metadata.get('last_edit') is not None + for key, value in kwargs.items(): + if metadata.get(key) != value: + return False + return True + + def assert_elastic(self, *args, invert: bool = False, **kwargs): + def assert_entry(get_entries): + for arg in args: + entry_id = 'test_entry_id_%d' % arg + entries = list(get_entries(entry_id)) + assert len(entries) > 0, entry_id + for entry in entries: + for key, value in kwargs.items(): + if key in ['authors', 'owners']: + ids = [user['user_id'] for user in entry.get(key)] + if ids != value: + return False + else: + if entry.get(key) != value: + return False + + return True + + # test v1 data + assert invert != assert_entry( + lambda id: search(owner=None, query=dict(entry_id=id)).data) + + def test_edit_all_properties(self, test_user, other_test_user): + edit_data = dict( + comment='test_edit_props', + references=['http://test', 'http://test2'], + coauthors=[other_test_user.user_id], + shared_with=[other_test_user.user_id]) + rv = self.perform_edit(**edit_data, query=self.query('upload_1')) + result = rv.json() + assert rv.status_code == 200, result + actions = result.get('actions') + for key in edit_data: + assert key in actions + quantity_actions = actions.get(key) + if not isinstance(quantity_actions, list): + quantity_actions = [quantity_actions] + for quantity_action in quantity_actions: + assert quantity_action['success'] + + assert self.mongo(1, comment='test_edit_props') + assert self.mongo(1, references=['http://test', 'http://test2']) + assert self.mongo(1, coauthors=[other_test_user.user_id]) + assert self.mongo(1, shared_with=[other_test_user.user_id]) + + self.assert_elastic(1, comment='test_edit_props') + self.assert_elastic(1, references=['http://test', 'http://test2']) + self.assert_elastic(1, authors=[test_user.user_id, other_test_user.user_id]) + self.assert_elastic(1, owners=[test_user.user_id, other_test_user.user_id]) + + edit_data = dict( + comment='', + references=[], + coauthors=[], + shared_with=[]) + rv = self.perform_edit(**edit_data, query=self.query('upload_1')) + result = rv.json() + assert rv.status_code == 200 + actions = result.get('actions') + for key in edit_data: + assert key in actions + quantity_actions = actions.get(key) + if not isinstance(quantity_actions, list): + quantity_actions = [quantity_actions] + for quantity_action in quantity_actions: + assert quantity_action['success'] + + assert self.mongo(1, comment=None) + assert self.mongo(1, references=[]) + assert self.mongo(1, coauthors=[]) + assert self.mongo(1, shared_with=[]) + + self.assert_elastic(1, comment=None) + self.assert_elastic(1, references=[]) + self.assert_elastic(1, authors=[test_user.user_id]) + self.assert_elastic(1, owners=[test_user.user_id]) + + def test_edit_all(self): + rv = self.perform_edit(comment='test_edit_all') + self.assert_edit(rv, quantity='comment', success=True, message=False) + assert self.mongo(1, 2, 3, comment='test_edit_all') + self.assert_elastic(1, 2, 3, comment='test_edit_all') + assert not self.mongo(4, comment='test_edit_all', edited=False) + self.assert_elastic(4, comment='test_edit_all', edited=False, invert=True) + + def test_edit_multi(self): + rv = self.perform_edit(comment='test_edit_multi', query=self.query('upload_1', 'upload_2')) + self.assert_edit(rv, quantity='comment', success=True, message=False) + assert self.mongo(1, 2, 3, comment='test_edit_multi') + self.assert_elastic(1, 2, 3, comment='test_edit_multi') + assert not self.mongo(4, comment='test_edit_multi', edited=False) + self.assert_elastic(4, comment='test_edit_multi', edited=False, invert=True) + + def test_edit_some(self): + rv = self.perform_edit(comment='test_edit_some', query=self.query('upload_1')) + self.assert_edit(rv, quantity='comment', success=True, message=False) + assert self.mongo(1, comment='test_edit_some') + self.assert_elastic(1, comment='test_edit_some') + assert not self.mongo(2, 3, 4, comment='test_edit_some', edited=False) + self.assert_elastic(2, 3, 4, comment='test_edit_some', edited=False, invert=True) + + def test_edit_verify(self): + rv = self.perform_edit( + comment='test_edit_verify', verify=True, query=self.query('upload_1')) + self.assert_edit(rv, quantity='comment', success=True, message=False) + assert not self.mongo(1, comment='test_edit_verify', edited=False) + + def test_edit_empty_list(self, other_test_user): + rv = self.perform_edit(coauthors=[other_test_user.user_id], query=self.query('upload_1')) + self.assert_edit(rv, quantity='coauthors', success=True, message=False) + rv = self.perform_edit(coauthors=[], query=self.query('upload_1')) + self.assert_edit(rv, quantity='coauthors', success=True, message=False) + assert self.mongo(1, coauthors=[]) + + def test_edit_duplicate_value(self, other_test_user): + rv = self.perform_edit(coauthors=[other_test_user.user_id, other_test_user.user_id], query=self.query('upload_1')) + self.assert_edit(rv, status_code=400, quantity='coauthors', success=False, message=True) + + def test_edit_uploader_as_coauthor(self, test_user): + rv = self.perform_edit(coauthors=[test_user.user_id], query=self.query('upload_1')) + self.assert_edit(rv, status_code=400, quantity='coauthors', success=False, message=True) + + def test_edit_ds(self): + rv = self.perform_edit( + datasets=[self.example_dataset.name], query=self.query('upload_1')) + self.assert_edit(rv, quantity='datasets', success=True, message=False) + assert self.mongo(1, datasets=[self.example_dataset.dataset_id]) + + def test_edit_ds_remove_doi(self): + rv = self.perform_edit( + datasets=[self.example_dataset.name], query=self.query('upload_1')) + + assert rv.status_code == 200 + rv = self.api.post('datasets/%s/doi' % self.example_dataset.name, headers=self.test_user_auth) + assert rv.status_code == 200 + rv = self.perform_edit(datasets=[], query=self.query('upload_1')) + assert rv.status_code == 400 + data = rv.json() + assert not data['success'] + assert self.example_dataset.name in data['message'] + assert Dataset.m_def.a_mongo.get(dataset_id=self.example_dataset.dataset_id) is not None + + def test_edit_ds_remove(self): + rv = self.perform_edit( + datasets=[self.example_dataset.name], query=self.query('upload_1')) + assert rv.status_code == 200 + rv = self.perform_edit(datasets=[], query=self.query('upload_1')) + assert rv.status_code == 200 + with pytest.raises(KeyError): + assert Dataset.m_def.a_mongo.get(dataset_id=self.example_dataset.dataset_id) is None + + def test_edit_ds_user_namespace(self, test_user): + assert Dataset.m_def.a_mongo.objects( + name=self.other_example_dataset.name).first() is not None + + rv = self.perform_edit( + datasets=[self.other_example_dataset.name], query=self.query('upload_1')) + + self.assert_edit(rv, quantity='datasets', success=True, message=True) + new_dataset = Dataset.m_def.a_mongo.objects( + name=self.other_example_dataset.name, + user_id=test_user.user_id).first() + assert new_dataset is not None + assert self.mongo(1, datasets=[new_dataset.dataset_id]) + + def test_edit_new_ds(self, test_user): + rv = self.perform_edit(datasets=['new_dataset'], query=self.query('upload_1')) + self.assert_edit(rv, quantity='datasets', success=True, message=True) + new_dataset = Dataset.m_def.a_mongo.objects(name='new_dataset').first() + assert new_dataset is not None + assert new_dataset.user_id == test_user.user_id + assert self.mongo(1, datasets=[new_dataset.dataset_id]) + + def test_edit_bad_user(self): + rv = self.perform_edit(coauthors=['bad_user'], query=self.query('upload_1')) + self.assert_edit(rv, status_code=400, quantity='coauthors', success=False, message=True) + + def test_edit_user(self, other_test_user): + rv = self.perform_edit(coauthors=[other_test_user.user_id], query=self.query('upload_1')) + self.assert_edit(rv, quantity='coauthors', success=True, message=False) + + @pytest.mark.skip(reason='Not necessary during transition. Fails because uploader is not editable anyways.') + def test_admin_only(self, other_test_user): + rv = self.perform_edit(uploader=other_test_user.user_id) + assert rv.status_code != 200 diff --git a/tests/app/v1/routers/test_info.py b/tests/app/v1/routers/test_info.py new file mode 100644 index 0000000000000000000000000000000000000000..73007bb34e40aef157b7376b6b72435863c48f57 --- /dev/null +++ b/tests/app/v1/routers/test_info.py @@ -0,0 +1,33 @@ +# +# 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. +# + + +def test_info(client, elastic): + rv = client.get('info') + assert rv.status_code == 200 + + data = rv.json() + assert 'codes' in data + assert 'parsers' in data + assert 'statistics' in data + assert len(data['parsers']) >= len(data['codes']) + assert len(data['domains']) >= 1 + assert rv.status_code == 200 + + rv = client.get('info') + assert rv.status_code == 200 diff --git a/tests/app/v1/routers/test_users.py b/tests/app/v1/routers/test_users.py index f1c513e6dfc2b726d4043b4abf840d40e06aee77..d08aadc6b6a2f11212493c23e2fbc6ac5b70a69b 100644 --- a/tests/app/v1/routers/test_users.py +++ b/tests/app/v1/routers/test_users.py @@ -30,3 +30,35 @@ def test_me_auth_required(client): def test_me_auth_bad_token(client): response = client.get('users/me', headers={'Authentication': 'Bearer NOTATOKEN'}) assert response.status_code == 401 + + +def test_invite(client, test_user_auth, no_warn): + rv = client.put( + 'users/invite', headers=test_user_auth, json={ + 'first_name': 'John', + 'last_name': 'Doe', + 'affiliation': 'Affiliation', + 'email': 'john.doe@affiliation.edu' + }) + assert rv.status_code == 200 + data = rv.json() + keys = data.keys() + required_keys = ['name', 'email', 'user_id'] + assert all(key in keys for key in required_keys) + + +def test_users(client): + rv = client.get('users?prefix=Sheldon') + assert rv.status_code == 200 + + data = rv.json() + assert len(data['data']) == 1 + user = data['data'][0] + + for key in ['name', 'user_id']: + assert key in user + + for value in user.values(): + assert value is not None + + assert 'email' not in user diff --git a/tests/normalizing/test_system.py b/tests/normalizing/test_system.py index 49dde1d4bab6e3741605eebcb3d24ff2ee842b75..cee4934f0566c50cf8944258762426dae164b0f3 100644 --- a/tests/normalizing/test_system.py +++ b/tests/normalizing/test_system.py @@ -116,6 +116,10 @@ def assert_normalized(entry_archive: datamodel.EntryArchive): assert metadata[key] != config.services.unavailable_value, '%s must not be unavailable' % key + assert entry_archive.metadata + assert entry_archive.metadata.quantities + assert len(entry_archive.metadata.quantities) > 0 + # check if the result can be dumped dump_json(entry_archive.m_to_dict()) diff --git a/tests/processing/test_data.py b/tests/processing/test_data.py index e242a31825296aba59c798da7fa8c6d21f95417e..8f9cb3563d278bd77ebf69f5c2ed7c96bdb15eba 100644 --- a/tests/processing/test_data.py +++ b/tests/processing/test_data.py @@ -126,7 +126,10 @@ def assert_processing(upload: Upload, published: bool = False, process='process_ with upload_files.raw_file(path) as f: f.read() - # check some domain metadata + # check some (domain) metadata + assert entry_metadata.quantities + assert len(entry_metadata.quantities) > 0 + assert entry_metadata.n_atoms > 0 assert len(entry_metadata.atoms) > 0 assert len(entry_metadata.processing_errors) == 0