Commit 06cbcadb authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Added uploads list to userdata page.

parent b27e6a7b
Pipeline #63619 passed with stages
in 16 minutes and 47 seconds
......@@ -32,7 +32,7 @@ class DatasetPage extends React.Component {
header: {
display: 'flex',
flexDirection: 'row',
padding: theme.spacing.unit * 3,
padding: theme.spacing.unit * 3
},
actions: {}
})
......@@ -52,7 +52,8 @@ class DatasetPage extends React.Component {
api.search({
owner: 'all',
dataset_id: datasetId,
page: 1, per_page: 1
page: 1,
per_page: 1
}).then(data => {
const entry = data.results[0]
const dataset = entry && entry.datasets.find(ds => ds.id + '' === datasetId)
......@@ -109,7 +110,10 @@ class DatasetPage extends React.Component {
</div>
</div>
<SearchContext query={{dataset_id: datasetId}} ownerTypes={['all', 'public']} update={update}>
<SearchContext
query={{dataset_id: datasetId}} ownerTypes={['all', 'public']} update={update}
initialRequest={{datasets: true}}
>
<Search resultTab="entries"/>
</SearchContext>
</div>
......
......@@ -21,7 +21,10 @@ class UserdataPage extends React.Component {
render() {
return (
<div>
<SearchContext ownerTypes={['user', 'staging']} initialQuery={{owner: 'user'}} >
<SearchContext
ownerTypes={['user', 'staging']} initialQuery={{owner: 'user'}}
initialRequest={{uploads: true, datasets: true}}
>
<Search resultTab="entries"/>
</SearchContext>
</div>
......
......@@ -18,6 +18,7 @@ import { CopyToClipboard } from 'react-copy-to-clipboard'
class DOIUnstyled extends React.Component {
static propTypes = {
classes: PropTypes.object.isRequired,
doi: PropTypes.string.isRequired
}
......
......@@ -11,6 +11,7 @@ import { withDomain } from '../domains'
import KeepState from '../KeepState'
import PeriodicTable from './PeriodicTable'
import ReloadIcon from '@material-ui/icons/Cached'
import UploadList from './UploadsList'
class Search extends React.Component {
static propTypes = {
......@@ -41,7 +42,7 @@ class Search extends React.Component {
maxWidth: 900,
margin: 'auto',
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit * 2
},
searchResults: {
marginTop: theme.spacing.unit * 4
......@@ -59,6 +60,8 @@ class Search extends React.Component {
}
}
static contextType = SearchContext.type
state = {
resultTab: this.resultTab || 'entries',
openVisualization: this.props.visualization
......@@ -81,6 +84,7 @@ class Search extends React.Component {
render() {
const {classes} = this.props
const {resultTab, openVisualization} = this.state
const {state: {request: {uploads, datasets}}} = this.context
return <DisableOnLoading>
<div className={classes.root}>
......@@ -119,17 +123,22 @@ class Search extends React.Component {
onChange={(event, value) => this.setState({resultTab: value})}
>
<Tab label="Entries" value="entries" />
<Tab label="Datasets" value="datasets" />
{datasets && <Tab label="Datasets" value="datasets" />}
{uploads && <Tab label="Uploads" value="uploads" />}
</Tabs>
<KeepState
visible={resultTab === 'entries'}
render={() => <SearchEntryList />}
/>
<KeepState
{datasets && <KeepState
visible={resultTab === 'datasets'}
render={() => <SearchDatasetList />}
/>
/>}
{uploads && <KeepState
visible={resultTab === 'uploads'}
render={() => <SearchUploadList />}
/>}
</Paper>
</div>
</div>
......@@ -212,7 +221,6 @@ class ElementsVisualization extends React.Component {
}
class MetricSelectUnstyled extends React.Component {
static propTypes = {
classes: PropTypes.object.isRequired,
domain: PropTypes.object.isRequired
......@@ -420,4 +428,19 @@ class SearchDatasetList extends React.Component {
}
}
class SearchUploadList extends React.Component {
static contextType = SearchContext.type
render() {
const {state: {response}, setRequest} = this.context
return <UploadList data={response}
total={response.statistics.total.all.uploads}
onChange={setRequest}
actions={<ReRunSearchButton/>}
{...response}
/>
}
}
export default withStyles(Search.styles)(Search)
......@@ -12,7 +12,12 @@ class SearchContext extends React.Component {
static propTypes = {
query: PropTypes.object,
initialQuery: PropTypes.object,
update: PropTypes.number
initialRequest: PropTypes.object,
update: PropTypes.number,
domain: PropTypes.object.isRequired,
api: PropTypes.object.isRequired,
raiseError: PropTypes.func.isRequired,
children: PropTypes.any
}
static emptyResponse = {
......@@ -24,6 +29,10 @@ class SearchContext extends React.Component {
after: null,
values: []
},
uploads: {
after: null,
values: []
},
statistics: {
total: {
all: {
......@@ -41,6 +50,9 @@ class SearchContext extends React.Component {
this.handleQueryChange = this.handleQueryChange.bind(this)
this.handleMetricChange = this.handleMetricChange.bind(this)
this.state.query = this.props.initialQuery || {}
if (this.props.initialRequest) {
this.state.request = {...this.state.request, ...this.props.initialRequest}
}
}
defaultMetric = this.props.domain.defaultSearchMetric
......@@ -52,9 +64,7 @@ class SearchContext extends React.Component {
order_by: 'formula',
order: 1,
page: 1,
per_page: 10,
datasets: true,
datasets_after: null
per_page: 10
},
metric: this.defaultMetric,
usedMetric: this.defaultMetric,
......@@ -104,7 +114,7 @@ class SearchContext extends React.Component {
this.setState({response: response || SearchContext.emptyResponse, usedMetric: usedMetric})
}).catch(error => {
this.setState({response: SearchContext.emptyResponse})
if (error.name !== 'NotAuthorized' || this.props.searchParameters.owner === 'all') {
if (error.name !== 'NotAuthorized') {
raiseError(error)
}
})
......
......@@ -83,7 +83,7 @@ class SearchPage extends React.Component {
return (
<div className={classes.root}>
<SearchContext
initialQuery={query}
initialQuery={query} initialRequest={{datasets: true}}
ownerTypes={['all', 'public'].filter(key => user || withoutLogin.indexOf(key) !== -1)}
>
<Search visualization="elements" />
......
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 { withDomain } from '../domains'
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'
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,
onChange: PropTypes.func
}
static styles = theme => ({
group: {
flexWrap: 'nowrap',
flexDirection: 'row-reverse'
}
})
constructor(props) {
super(props)
this.handleEdit = this.handleEdit.bind(this)
}
handleEdit() {
const {onChange, upload} = this.props
if (onChange) {
onChange(upload)
}
}
render() {
const {upload, user, classes} = this.props
const editable = user && upload.example &&
upload.example.authors.find(author => author.user_id === user.sub)
const query = {upload_id: upload.example.upload_id}
return <FormGroup row classes={{root: classes.group}}>
{<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(withApi(false), withStyles(UploadActionsUnstyled.styles))(UploadActionsUnstyled)
class UploadListUnstyled extends React.Component {
static propTypes = {
classes: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
total: PropTypes.number.isRequired,
onChange: PropTypes.func.isRequired,
history: PropTypes.any.isRequired,
uploads_after: PropTypes.string,
actions: PropTypes.element
}
static styles = theme => ({
root: {
overflow: 'auto',
paddingLeft: theme.spacing.unit * 2,
paddingRight: theme.spacing.unit * 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_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',
render: (upload) => upload.example.published ? 'Yes' : 'No'
}
}
renderEntryActions(entry) {
const {onChange} = this.props
return <UploadActions search upload={entry} onChange={() => onChange({})} />
}
render() {
const { classes, data, total, uploads_after, onChange, actions } = this.props
const uploads = data.uploads || {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} of ${total}`
} else {
paginationText = `1-${results.length} of ${total}`
}
const pagination = <TableCell colSpan={1000} classes={{root: classes.scrollCell}}>
<Toolbar className={classes.scrollBar}>
<span className={classes.scrollSpacer}>&nbsp;</span>
<span>{paginationText}</span>
<IconButton disabled={!uploads_after} onClick={() => onChange({uploads_after: null})}>
<StartIcon />
</IconButton>
<IconButton disabled={results.length < per_page} onClick={() => onChange({uploads_after: after})}>
<NextIcon />
</IconButton>
</Toolbar>
</TableCell>
return <DataTable
title={`${total.toLocaleString()} uploads`}
id={row => row.id}
total={total}
columns={this.columns}
selectedColumns={['upload_time', 'upload_name', 'upload_id', 'entries', 'published']}
entryActions={this.renderEntryActions}
data={results}
rows={per_page}
actions={actions}
pagination={pagination}
/>
}
}
const UploadList = compose(withRouter, withDomain, withApi(false), withStyles(UploadListUnstyled.styles))(UploadListUnstyled)
export default UploadList
......@@ -80,7 +80,11 @@ repo_calcs_model = api.model('RepoCalculations', {
' metrics over all results. ' % ', '.join(datamodel.Domain.instance.metrics_names))),
'datasets': fields.Nested(api.model('RepoDatasets', {
'after': fields.String(description='The after value that can be used to retrieve the next datasets.'),
'values': fields.Raw(description='A dict with names as key. The values are dicts with "total" and "examples" keys.')
'values': fields.Raw(description='A dict with dataset id as key. The values are dicts with "total" and "examples" keys.')
}), skip_none=True),
'uploads': fields.Nested(api.model('RepoUploads', {
'after': fields.String(description='The after value that can be used to retrieve the next uploads.'),
'values': fields.Raw(description='A dict with upload ids as key. The values are dicts with "total" and "examples" keys.')
}), skip_none=True)
})
......@@ -117,12 +121,16 @@ repo_request_parser.add_argument(
'date_histogram', type=bool, help='Add an additional aggregation over the upload time')
repo_request_parser.add_argument(
'datasets_after', type=str, help='The last dataset id of the last scroll window for the dataset quantity')
repo_request_parser.add_argument(
'uploads_after', type=str, help='The last upload id of the last scroll window for the upload quantity')
repo_request_parser.add_argument(
'metrics', type=str, action='append', help=(
'Metrics to aggregate over all quantities and their values as comma separated list. '
'Possible values are %s.' % ', '.join(datamodel.Domain.instance.metrics_names)))
repo_request_parser.add_argument(
'datasets', type=bool, help=('Return dataset information.'))
repo_request_parser.add_argument(
'uploads', type=bool, help=('Return upload information.'))
repo_request_parser.add_argument(
'statistics', type=bool, help=('Return statistics.'))
......@@ -229,7 +237,8 @@ class RepoCalcsResource(Resource):
metrics: List[str] = request.args.getlist('metrics')
with_datasets = args.get('datasets', False)
with_statistics = args.get('statistics', False)
with_uploads = args.get('uploads', False)
with_statistics = args.get('statistics', False) or with_datasets or with_uploads
except Exception as e:
abort(400, message='bad parameters: %s' % str(e))
......@@ -253,10 +262,15 @@ class RepoCalcsResource(Resource):
if with_statistics:
search_request.default_statistics(metrics_to_use=metrics)
if 'datasets' not in metrics:
total_metrics = metrics + ['datasets']
else:
total_metrics = metrics
additional_metrics = []
if with_datasets and 'datasets' not in metrics:
additional_metrics.append('datasets')
if with_uploads and 'uploads' not in metrics:
additional_metrics.append('uploads')
total_metrics = metrics + additional_metrics
search_request.totals(metrics_to_use=total_metrics)
search_request.statistic('authors', 1000)
......@@ -270,6 +284,11 @@ class RepoCalcsResource(Resource):
'dataset_id', size=per_page, examples=1,
after=request.args.get('datasets_after', None))
if with_uploads:
search_request.quantity(
'upload_id', size=per_page, examples=1,
after=request.args.get('uploads_after', None))
results = search_request.execute_paginated(
per_page=per_page, page=page, order=order, order_by=order_by)
......@@ -279,10 +298,17 @@ class RepoCalcsResource(Resource):
if 'code_name' in statistics and 'currupted mainfile' in statistics['code_name']:
del(statistics['code_name']['currupted mainfile'])
if 'quantities' in results:
quantities = results.pop('quantities')
if with_datasets:
datasets = results.pop('quantities')['dataset_id']
datasets = quantities['dataset_id']
results['datasets'] = datasets
if with_uploads:
uploads = quantities['upload_id']
results['uploads'] = uploads
return results, 200
except search.ScrollIdNotFound:
abort(400, 'The given scroll_id does not exist.')
......
......@@ -333,6 +333,7 @@ class Domain:
base_metrics = dict(
datasets=('datasets.id', 'cardinality'),
uploads=('upload_id', 'cardinality'),
uploaders=('uploader.name.keyword', 'cardinality'),
authors=('authors.name.keyword', 'cardinality'),
unique_entries=('calc_hash', 'cardinality'))
......
......@@ -612,7 +612,8 @@ class TestRepo():
dataset_id='ds_id', name='ds_name', user_id=test_user.user_id, doi='ds_doi')
example_dataset.m_x('me').create()
calc_with_metadata = CalcWithMetadata(upload_id=0, calc_id=0, upload_time=today)
calc_with_metadata = CalcWithMetadata(
upload_id='example_upload_id', calc_id='0', upload_time=today)
calc_with_metadata.files = ['test/mainfile.txt']
calc_with_metadata.apply_domain_metadata(normalized)
......@@ -696,6 +697,19 @@ class TestRepo():
assert values['ds_id']['total'] == 4
assert values['ds_id']['examples'][0]['datasets'][0]['id'] == 'ds_id'
assert 'after' in datasets
assert 'datasets' in data['statistics']['total']['all']
def test_search_uploads(self, api, example_elastic_calcs, no_warn, other_test_user_auth):
rv = api.get('/repo/?owner=all&uploads=true', headers=other_test_user_auth)
data = self.assert_search(rv, 4)
uploads = data.get('uploads', None)
assert uploads is not None
values = uploads['values']
assert values['example_upload_id']['total'] == 4
assert values['example_upload_id']['examples'][0]['upload_id'] == 'example_upload_id'
assert 'after' in uploads
assert 'uploads' in data['statistics']['total']['all']
@pytest.mark.parametrize('calcs, owner, auth', [
(2, 'all', 'none'),
......@@ -795,7 +809,7 @@ class TestRepo():
@pytest.mark.parametrize('metrics', metrics_permutations)
def test_search_total_metrics(self, api, example_elastic_calcs, no_warn, metrics):
rv = api.get('/repo/?%s' % urlencode(dict(metrics=metrics, statistics=True, datasets=True), doseq=True))
rv = api.get('/repo/?%s' % urlencode(dict(metrics=metrics, statistics=True, datasets=True, uploads=True), doseq=True))
assert rv.status_code == 200, str(rv.data)
data = json.loads(rv.data)
total_metrics = data.get('statistics', {}).get('total', {}).get('all', None)
......@@ -806,7 +820,7 @@ class TestRepo():
@pytest.mark.parametrize('metrics', metrics_permutations)
def test_search_aggregation_metrics(self, api, example_elastic_calcs, no_warn, metrics):
rv = api.get('/repo/?%s' % urlencode(dict(metrics=metrics, statistics=True, datasets=True), doseq=True))
rv = api.get('/repo/?%s' % urlencode(dict(metrics=metrics, statistics=True, datasets=True, uploads=True), doseq=True))
assert rv.status_code == 200
data = json.loads(rv.data)
for name, quantity in data.get('statistics').items():
......@@ -943,7 +957,7 @@ class TestRepo():
assert rv.status_code == 200 if success else 404
if success:
assert json.loads(rv.data)['calc_id'] == '%d' % pid
assert json.loads(rv.data)['upload_id'] == '0'
assert json.loads(rv.data)['upload_id'] == 'example_upload_id'
@pytest.mark.timeout(config.tests.default_timeout)
def test_raw_id(self, api, test_user, test_user_auth, proc_infra):
......
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