Commit a36082f0 authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Merge branch 'UploadPageAuth' into 'v1.0.0'

Upload page for non authors

See merge request !450
parents 6dbd816f 690ad020
Pipeline #115186 passed with stages
in 29 minutes and 21 seconds
......@@ -60,7 +60,7 @@ function handleApiError(e) {
let error = null
if (e.response) {
const body = e.response.body
const message = (body && (body.message || body.description)) || e.response.statusText
const message = (body && (body.message || body.description)) || e.response?.data?.detail || e.response.statusText
const errorMessage = `${message} (${e.response.status})`
if (e.response.status === 404) {
error = new DoesNotExist(errorMessage)
......
......@@ -40,7 +40,7 @@ very similar to labels, albums, or tags on other platforms.
const columns = [
{
key: 'dataset_id',
render: dataset => <Quantity quantity={'dataset_id'} noLabel noWrap withClipboard data={dataset}/>
render: dataset => <Quantity quantity={'datasets.dataset_id'} noLabel noWrap withClipboard data={{datasets: dataset}}/>
},
{key: 'dataset_name'},
{key: 'doi', label: 'Digital object identifier (DOI)'},
......
......@@ -36,7 +36,7 @@ const UploadDownloadButton = React.memo(function UploadDownloadButton(props) {
let queryStringData = toAPIFilter(query)
const owner = query.visibility || 'visible'
const openDownload = (token) => {
const url = `${apiBase}/v1/uploads/${query.upload_id}/raw?offset=0&length=-1&compress=true&owner=${owner}&signature_token=${token}&json_query=${JSON.stringify(queryStringData)}`
const url = `${apiBase}/v1/uploads/${query.upload_id}/raw?offset=0&length=-1&compress=true&owner=${owner}${token ? '&signature_token=' + token : ''}&json_query=${JSON.stringify(queryStringData)}`
FileSaver.saveAs(url, `nomad-download.zip`)
}
......
......@@ -16,7 +16,7 @@
* limitations under the License.
*/
import React, { useMemo, useState } from 'react'
import React, {useContext, useMemo, useState} from 'react'
import PropTypes from 'prop-types'
import { Paper, Link } from '@material-ui/core'
import EntryDetails, { EntryRowActions } from '../entry/EntryDetails'
......@@ -27,6 +27,7 @@ import {
import EntryDownloadButton from '../entry/EntryDownloadButton'
import EditUserMetadataDialog from '../entry/EditUserMetadataDialog'
import Quantity from '../Quantity'
import {uploadPageContext} from './UploadPage'
const columns = [
{
......@@ -85,7 +86,8 @@ const defaultSelectedColumns = [
export default function ProcessingTable(props) {
const [selected, setSelected] = useState([])
const {data, pagination, onPaginationChanged, upload} = props
const {data, pagination, onPaginationChanged} = props
const {upload, isWriter} = useContext(uploadPageContext)
const selectedQuery = useMemo(() => {
if (selected === 'all') {
......@@ -103,7 +105,7 @@ export default function ProcessingTable(props) {
<DatatableToolbar title={`${pagination.total} search results`}>
<DatatableToolbarActions selection>
<EntryDownloadButton tooltip="Download files" query={selectedQuery} />
{!upload.published && <EditUserMetadataDialog
{isWriter && <EditUserMetadataDialog
example={selected === 'all' ? data[0] : selected[0]}
query={selectedQuery}
total={pagination.total}
......@@ -121,7 +123,6 @@ export default function ProcessingTable(props) {
}
ProcessingTable.propTypes = {
data: PropTypes.arrayOf(PropTypes.object).isRequired,
upload: PropTypes.object.isRequired,
pagination: PropTypes.object.isRequired,
onPaginationChanged: PropTypes.func.isRequired
}
......@@ -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 '../api'
import { DoesNotExist, useApi } from '../api'
import { useParams } from 'react-router'
import { useHistory, useLocation } from 'react-router-dom'
import FilesBrower from './FilesBrowser'
......@@ -43,6 +43,8 @@ import { getUrl } from '../nav/Routes'
import { combinePagination } from '../datatable/Datatable'
import UploadDownloadButton from '../entry/UploadDownloadButton'
export const uploadPageContext = React.createContext()
const useDropButtonStyles = makeStyles(theme => ({
dropzone: {
width: '100%'
......@@ -295,7 +297,7 @@ const useStyles = makeStyles(theme => ({
function UploadPage() {
const classes = useStyles()
const { uploadId } = useParams()
const {api} = useApi()
const {api, user} = useApi()
const errors = useErrors()
const history = useHistory()
const location = useLocation()
......@@ -306,6 +308,7 @@ function UploadPage() {
const [deleteClicked, setDeleteClicked] = useState(false)
const [data, setData] = useState(null)
const [uploading, setUploading] = useState(null)
const [err, setErr] = useState(null)
const upload = data?.upload
const setUpload = useMemo(() => (upload) => {
setData(data => ({...data, upload: upload}))
......@@ -320,7 +323,7 @@ function UploadPage() {
if (error instanceof DoesNotExist && deleteClicked) {
history.push(getUrl('uploads', location))
} else {
errors.raiseError(error)
(error.apiMessage ? setErr(error.apiMessage) : errors.raiseError(error))
}
})
}, [api, uploadId, pagination, deleteClicked, history, errors, location])
......@@ -378,137 +381,150 @@ function UploadPage() {
.catch(errors.raiseError)
}
const viewers = upload?.viewers
const writers = upload?.writers
const isViewer = user && viewers?.includes(user.sub)
const isWriter = user && writers?.includes(user.sub)
const contextValue = useMemo(() => ({
upload: upload,
isViewer: isViewer,
isWriter: isWriter
}), [upload, isViewer, isWriter])
if (!upload) {
return <Page limitedWidth>
<Typography>loading ...</Typography>
{(err ? <Typography> {err} </Typography> : <Typography>loading ...</Typography>)}
</Page>
}
const isAuthenticated = api.keycloak.authenticated
const isPublished = upload.published
const isEmpty = upload.entries === 0
return <Page limitedWidth>
{(uploading || uploading === 0) && <Dialog open>
<DialogTitle>Uploading file ...</DialogTitle>
<DialogContent>
<Box width={300}>
<LinearProgressWithLabel value={uploading} />
</Box>
</DialogContent>
</Dialog>}
<Slide direction="down" in={isProcessing} mountOnEnter unmountOnExit>
<Paper className={classes.status}>
<Page limitedWidth>
<Grid container spacing={2} alignItems="center">
<Grid item>
<CircularProgress />
</Grid>
<Grid item style={{flexGrow: 1}}>
<Typography>Upload is processing ...</Typography>
<Typography>{data.upload.last_status_message}</Typography>
return <uploadPageContext.Provider value={contextValue}>
<Page limitedWidth>
{(uploading || uploading === 0) && <Dialog open>
<DialogTitle>Uploading file ...</DialogTitle>
<DialogContent>
<Box width={300}>
<LinearProgressWithLabel value={uploading} />
</Box>
</DialogContent>
</Dialog>}
<Slide direction="down" in={isProcessing} mountOnEnter unmountOnExit>
<Paper className={classes.status}>
<Page limitedWidth>
<Grid container spacing={2} alignItems="center">
<Grid item>
<CircularProgress />
</Grid>
<Grid item style={{flexGrow: 1}}>
<Typography>Upload is processing ...</Typography>
<Typography>{data.upload.last_status_message}</Typography>
</Grid>
</Grid>
</Grid>
</Page>
</Paper>
</Slide>
<Grid container spacing={2} alignItems="center">
<Grid item>
<UploadStatus upload={upload} fontSize="large" />
</Grid>
<Grid item style={{flexGrow: 1}}>
<UploadName upload_name={upload?.upload_name} onChange={handleNameChange} />
<WithButton clipboard={uploadId}>
<Typography>upload id: {uploadId}</Typography>
</WithButton>
</Grid>
<Grid>
<UploadDownloadButton tooltip="Download files" query={{'upload_id': uploadId}} />
<IconButton disabled={isPublished} onClick={handleReprocess}>
<Tooltip title="Reprocess">
<ReprocessIcon />
</Tooltip>
</IconButton>
<IconButton disabled={isPublished} onClick={handleDelete}>
<Tooltip title="Delete the upload">
<DeleteIcon />
</Tooltip>
</IconButton>
</Page>
</Paper>
</Slide>
<Grid container spacing={2} alignItems="center">
<Grid item>
<UploadStatus upload={upload} fontSize="large" />
</Grid>
<Grid item style={{flexGrow: 1}}>
<UploadName upload_name={upload?.upload_name} onChange={handleNameChange} />
<WithButton clipboard={uploadId}>
<Typography>upload id: {uploadId}</Typography>
</WithButton>
</Grid>
<Grid>
<UploadDownloadButton tooltip="Download files" query={{'upload_id': uploadId}} />
<IconButton disabled={isPublished || !isWriter} onClick={handleReprocess}>
<Tooltip title="Reprocess">
<ReprocessIcon />
</Tooltip>
</IconButton>
<IconButton disabled={isPublished || !isWriter} onClick={handleDelete}>
<Tooltip title="Delete the upload">
<DeleteIcon />
</Tooltip>
</IconButton>
</Grid>
</Grid>
</Grid>
<Stepper classes={{root: classes.stepper}} orientation="vertical" nonLinear>
<Step expanded active={false}>
<StepLabel>Prepare and upload your files</StepLabel>
<StepContent>
{isPublished && <Typography className={classes.stepContent}>
This upload is published and it&apos;s files can&apos;t be modified anymore.
</Typography>}
{!isPublished && (
<React.Fragment>
<Typography className={classes.stepContent}>
To prepare your data, simply use <b>zip</b> or <b>tar</b> to create a single file that contains
all your files as they are. These .zip/.tar files can contain subdirectories and additional files.
NOMAD will search through all files and identify the relevant files automatically.
Each uploaded file can be <b>up to 32GB</b> in size, you can have <b>up to 10 unpublished
uploads</b> simultaneously. Your uploaded data is not published right away.
Find more details about uploading data in our <Link href={`${appBase}/docs/upload.html`}>documentation</Link> or visit
our <Link href="https://nomad-lab.eu/repository-archive-faqs">FAQs</Link>.
The following codes are supported: <CodeList withUploadInstructions />. Click
the code to get more specific information about how to prepare your files.
</Typography>
<DropButton
className={classes.stepContent}
size="large"
fullWidth onDrop={handleDrop}
disabled={isProcessing} />
</React.Fragment>
)}
<FilesBrower className={classes.stepContent} uploadId={uploadId} disabled={isProcessing || deleteClicked} />
</StepContent>
</Step>
<Step expanded={!isEmpty}>
<StepLabel>Process data</StepLabel>
<StepContent>
<ProcessingStatus data={data} />
<ProcessingTable
upload={upload}
data={data.data.map(entry => ({...entry.entry_metadata, ...entry}))}
pagination={combinePagination(pagination, data.pagination)}
onPaginationChanged={setPagination} />
</StepContent>
</Step>
<Step expanded={!isEmpty}>
<StepLabel>Edit metadata</StepLabel>
<StepContent>
<Typography className={classes.stepContent}>
You can add more information about your data, like <i>comments</i>, <i>references</i> (e.g. links
to publications), you can create <i>datasets</i> from your entries, or <i>share</i> private data
with outhers (e.g. before publishing or after publishing with an embargo.).
Please note that <b>we require you to list the <i>co-authors</i></b> before publishing.
</Typography>
<Typography className={classes.stepContent}>
You can either select and edit individual entries from the list above, or
edit all entries at once.
</Typography>
{!isEmpty && <EditUserMetadataDialog
example={data.data[0].entry_metadata || {}}
query={{'upload_id': [uploadId]}}
total={data.pagination.total}
onEditComplete={() => setPagination({...pagination})}
buttonProps={{variant: 'contained', color: 'primary', disabled: isProcessing}}
text={`Edit metadata of all ${data.pagination.total} entries`}
withoutLiftEmbargo={!isPublished}
/>}
</StepContent>
</Step>
<Step expanded={!isEmpty}>
<StepLabel>Publish</StepLabel>
<StepContent>
{isPublished && <Typography>This upload has already been published.</Typography>}
{!isPublished && <PublishUpload upload={upload} onPublish={handlePublish} />}
</StepContent>
</Step>
</Stepper>
</Page>
<Stepper classes={{root: classes.stepper}} orientation="vertical" nonLinear>
<Step expanded active={false}>
<StepLabel>Prepare and upload your files</StepLabel>
<StepContent>
{isPublished && <Typography className={classes.stepContent}>
This upload is published and it&apos;s files can&apos;t be modified anymore.
</Typography>}
{!isPublished && (
<React.Fragment>
<Typography className={classes.stepContent}>
To prepare your data, simply use <b>zip</b> or <b>tar</b> to create a single file that contains
all your files as they are. These .zip/.tar files can contain subdirectories and additional files.
NOMAD will search through all files and identify the relevant files automatically.
Each uploaded file can be <b>up to 32GB</b> in size, you can have <b>up to 10 unpublished
uploads</b> simultaneously. Your uploaded data is not published right away.
Find more details about uploading data in our <Link href={`${appBase}/docs/upload.html`}>documentation</Link> or visit
our <Link href="https://nomad-lab.eu/repository-archive-faqs">FAQs</Link>.
The following codes are supported: <CodeList withUploadInstructions />. Click
the code to get more specific information about how to prepare your files.
</Typography>
<DropButton
className={classes.stepContent}
size="large"
fullWidth onDrop={handleDrop}
disabled={isProcessing} />
</React.Fragment>
)}
<FilesBrower className={classes.stepContent} uploadId={uploadId} disabled={isProcessing || deleteClicked} />
</StepContent>
</Step>
<Step expanded={!isEmpty}>
<StepLabel>Process data</StepLabel>
<StepContent>
<ProcessingStatus data={data} />
<ProcessingTable
data={data.data.map(entry => ({...entry.entry_metadata, ...entry}))}
pagination={combinePagination(pagination, data.pagination)}
onPaginationChanged={setPagination}/>
</StepContent>
</Step>
{(isAuthenticated && isWriter) && <Step expanded={!isEmpty}>
<StepLabel>Edit metadata</StepLabel>
<StepContent>
<Typography className={classes.stepContent}>
You can add more information about your data, like <i>comments</i>, <i>references</i> (e.g. links
to publications), you can create <i>datasets</i> from your entries, or <i>share</i> private data
with others (e.g. before publishing or after publishing with an embargo.).
Please note that <b>we require you to list the <i>co-authors</i></b> before publishing.
</Typography>
<Typography className={classes.stepContent}>
You can either select and edit individual entries from the list above, or
edit all entries at once.
</Typography>
{!isEmpty && <EditUserMetadataDialog
example={data.data[0].entry_metadata || {}}
query={{'upload_id': [uploadId]}}
total={data.pagination.total}
onEditComplete={() => setPagination({...pagination})}
buttonProps={{variant: 'contained', color: 'primary', disabled: isProcessing}}
text={`Edit metadata of all ${data.pagination.total} entries`}
withoutLiftEmbargo={!isPublished}
/>}
</StepContent>
</Step>}
{(isAuthenticated && isWriter) && <Step expanded={!isEmpty}>
<StepLabel>Publish</StepLabel>
<StepContent>
{isPublished && <Typography>This upload has already been published.</Typography>}
{!isPublished && <PublishUpload upload={upload} onPublish={handlePublish} />}
</StepContent>
</Step>}
</Stepper>
</Page>
</uploadPageContext.Provider>
}
export default withLoginRequired(UploadPage)
export default UploadPage
......@@ -94,6 +94,21 @@ class UploadProcData(ProcData):
upload_create_time: datetime = Field(
None,
description='Date and time of the creation of the upload.')
main_author: str = Field(
None,
description=strip('The main author of the upload.'))
coauthors: List[str] = Field(
None,
description=strip('A list of upload coauthors.'))
reviewers: List[str] = Field(
None,
description=strip('A user provided list of reviewers.'))
viewers: List[str] = Field(
None,
description=strip('All viewers (main author, upload coauthors, and reviewers)'))
writers: List[str] = Field(
None,
description=strip('All writers (main author, upload coauthors)'))
published: bool = Field(
False,
description='If this upload is already published.')
......@@ -450,12 +465,12 @@ async def get_upload_entries(
...,
description='The unique id of the upload to retrieve entries for.'),
pagination: EntryProcDataPagination = Depends(entry_proc_data_pagination_parameters),
user: User = Depends(create_user_dependency(required=True))):
user: User = Depends(create_user_dependency())):
'''
Fetches the entries of a specific upload. Pagination is used to browse through the
results.
'''
upload = _get_upload_with_read_access(upload_id, user)
upload = _get_upload_with_read_access(upload_id, user, include_others=True)
order_by = pagination.order_by
order_by_with_sign = order_by if pagination.order == Direction.asc else '-' + order_by
......@@ -474,8 +489,8 @@ async def get_upload_entries(
}).query
metadata_entries = search(
pagination=MetadataPagination(page_size=len(entries)),
owner='admin' if user.is_admin else 'visible',
user_id=user.user_id,
owner='admin' if user and user.is_admin else 'visible',
user_id=user.user_id if user else None,
query=metadata_entries_query)
metadata_entries_map = {
metadata_entry['entry_id']: metadata_entry
......@@ -571,7 +586,7 @@ async def get_upload_raw_path(
Set if compressed files should be decompressed before streaming the
content (that is: if there are compressed files *within* the raw files).
Note, only some compression formats are supported.''')),
user: User = Depends(create_user_dependency(required=True, signature_token_auth_allowed=True))):
user: User = Depends(create_user_dependency(required=False, signature_token_auth_allowed=True))):
'''
For the upload specified by `upload_id`, gets the raw file or directory content located
at the given `path`. The data is zipped if `compress = true`.
......@@ -586,7 +601,7 @@ async def get_upload_raw_path(
specify `re_pattern` or `glob_pattern` to filter the files based on the file names.
'''
# Get upload
upload = _get_upload_with_read_access(upload_id, user)
upload = _get_upload_with_read_access(upload_id, user, include_others=True)
_check_upload_not_processing(upload)
# Get upload files
upload_files = UploadFiles.get(upload_id)
......@@ -1378,9 +1393,6 @@ def _get_upload_with_read_access(upload_id: str, user: User, include_others: boo
include_others: If uploads owned by others should be included. Access to the uploads
of other users is only granted if the upload is published and not under embargo.
'''
if not include_others and not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=strip('''
User authentication required to access upload.'''))
mongodb_query = _query_mongodb(upload_id=upload_id)
if not mongodb_query.count():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=strip('''
......
......@@ -593,7 +593,7 @@ class EntryMetadata(metainfo.MSection):
writers = metainfo.Quantity(
type=user_reference, shape=['0..*'],
description='All viewers (main author, upload coauthors, and reviewers)',
description='All writers (main author, upload coauthors)',
derived=lambda entry: ([entry.main_author] if entry.main_author is not None else []) + entry.coauthors,
a_elasticsearch=Elasticsearch(material_entry_type))
......
......@@ -809,8 +809,8 @@ class Upload(Proc):
upload_create_time = DateTimeField(required=True)
external_db = StringField()
main_author = StringField(required=True)
coauthors = ListField(default=None)
reviewers = ListField(StringField(), default=None)
coauthors = ListField(StringField(), default=[])
reviewers = ListField(StringField(), default=[])
last_update = DateTimeField()
publish_time = DateTimeField()
embargo_length = IntField(default=0, required=True)
......@@ -830,6 +830,14 @@ class Upload(Proc):
]
}
@property
def viewers(self):
return [self.main_author] + self.coauthors + self.reviewers
@property
def writers(self):
return [self.main_author] + self.coauthors
def __init__(self, **kwargs):
kwargs.setdefault('upload_create_time', datetime.utcnow())
super().__init__(**kwargs)
......
......@@ -182,7 +182,7 @@ class TestEditRepo():
assert self.mongo(1, comment=None)
assert self.mongo(1, references=[])
assert self.mongo(1, entry_coauthors=[])
assert self.mongo(1, reviewers=None)
assert self.mongo(1, reviewers=[])
self.assert_elastic(1, comment=None)
self.assert_elastic(1, references=None)
......
......@@ -177,6 +177,11 @@ def assert_upload(response_json, **kwargs):
assert 'upload_id' in response_json
assert 'upload_id' in data
assert 'upload_create_time' in data
assert 'main_author' in data
assert 'coauthors' in data
assert 'reviewers' in data
assert 'viewers' in data
assert 'writers' in data
assert 'published' in data
assert 'with_embargo' in data
assert 'embargo_length' in data
......@@ -687,9 +692,6 @@ def test_get_upload_entry(
pytest.param(dict(
user='test_user', upload_id='id_published', path='', accept='application/json'),
200, 'application/json', ['test_content'], id='published-dir-json-root'),
pytest.param(dict(
user='other_test_user', upload_id='id_published', path='test_content/subdir/test_entry_01/1.aux'),
401, None, None, id='published-file-unauthorized'),
pytest.param(dict(
user='admin_user', upload_id='id_published', path='test_content/subdir/test_entry_01/1.aux'),
200, 'text/plain; charset=utf-8', 'content', id='published-file-admin-auth'),
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment