未验证 提交 b5bac8c0 编写于 作者: B Boris Sekachev 提交者: GitHub

Jobs page: advanced filtration and implemented sorting (#4319)

Co-authored-by: NNikita Manovich <nikita.manovich@intel.com>
Co-authored-by: NMaya <maya17grd@gmail.com>
上级 c07a93d1
......@@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Basic page with jobs list, basic filtration to this list (<https://github.com/openvinotoolkit/cvat/pull/4258>)
- Added OpenCV.js TrackerMIL as tracking tool (<https://github.com/openvinotoolkit/cvat/pull/4200>)
- Ability to continue working from the latest frame where an annotator was before (<https://github.com/openvinotoolkit/cvat/pull/4297>)
- Advanced filtration and sorting for a list of jobs (<https://github.com/openvinotoolkit/cvat/pull/4319>)
### Changed
......
{
"name": "cvat-core",
"version": "4.2.0",
"version": "4.2.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "cvat-core",
"version": "4.2.0",
"version": "4.2.1",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
......
{
"name": "cvat-core",
"version": "4.2.0",
"version": "4.2.1",
"description": "Part of Computer Vision Tool which presents an interface for client-side integration",
"main": "babel.config.js",
"scripts": {
......
......@@ -11,7 +11,6 @@ const config = require('./config');
const {
isBoolean,
isInteger,
isEnum,
isString,
checkFilter,
checkExclusiveFields,
......@@ -19,14 +18,6 @@ const config = require('./config');
checkObjectType,
} = require('./common');
const {
TaskStatus,
TaskMode,
DimensionType,
CloudStorageProviderType,
CloudStorageCredentialsType,
} = require('./enums');
const User = require('./user');
const { AnnotationFormats } = require('./annotation-formats');
const { ArgumentError } = require('./exceptions');
......@@ -153,9 +144,9 @@ const config = require('./config');
cvat.jobs.get.implementation = async (filter) => {
checkFilter(filter, {
page: isInteger,
stage: isString,
state: isString,
assignee: isString,
filter: isString,
sort: isString,
search: isString,
taskID: isInteger,
jobID: isInteger,
});
......@@ -190,32 +181,22 @@ const config = require('./config');
checkFilter(filter, {
page: isInteger,
projectId: isInteger,
name: isString,
id: isInteger,
owner: isString,
assignee: isString,
search: isString,
filter: isString,
ordering: isString,
status: isEnum.bind(TaskStatus),
mode: isEnum.bind(TaskMode),
dimension: isEnum.bind(DimensionType),
});
checkExclusiveFields(filter, ['id', 'search', 'projectId'], ['page']);
checkExclusiveFields(filter, ['id', 'projectId'], ['page']);
const searchParams = {};
for (const field of [
'name',
'owner',
'assignee',
'filter',
'search',
'ordering',
'status',
'mode',
'id',
'page',
'projectId',
'dimension',
]) {
if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams[camelToSnake(field)] = filter[field];
......@@ -234,17 +215,14 @@ const config = require('./config');
checkFilter(filter, {
id: isInteger,
page: isInteger,
name: isString,
assignee: isString,
owner: isString,
search: isString,
status: isEnum.bind(TaskStatus),
filter: isString,
});
checkExclusiveFields(filter, ['id', 'search'], ['page']);
checkExclusiveFields(filter, ['id'], ['page']);
const searchParams = {};
for (const field of ['name', 'assignee', 'owner', 'search', 'status', 'id', 'page']) {
for (const field of ['filter', 'search', 'status', 'id', 'page']) {
if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams[camelToSnake(field)] = filter[field];
}
......@@ -267,38 +245,25 @@ const config = require('./config');
cvat.cloudStorages.get.implementation = async (filter) => {
checkFilter(filter, {
page: isInteger,
displayName: isString,
resourceName: isString,
description: isString,
filter: isString,
id: isInteger,
owner: isString,
search: isString,
providerType: isEnum.bind(CloudStorageProviderType),
credentialsType: isEnum.bind(CloudStorageCredentialsType),
});
checkExclusiveFields(filter, ['id', 'search'], ['page']);
const searchParams = new URLSearchParams();
for (const field of [
'displayName',
'credentialsType',
'providerType',
'owner',
'filter',
'search',
'id',
'page',
'description',
]) {
if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams.set(camelToSnake(field), filter[field]);
}
}
if (Object.prototype.hasOwnProperty.call(filter, 'resourceName')) {
searchParams.set('resource', filter.resourceName);
}
const cloudStoragesData = await serverProxy.cloudStorages.get(searchParams.toString());
const cloudStorages = cloudStoragesData.map((cloudStorage) => new CloudStorage(cloudStorage));
cloudStorages.count = cloudStoragesData.count;
......
// Copyright (C) 2019-2021 Intel Corporation
// Copyright (C) 2019-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -773,7 +773,7 @@ function build() {
/**
* @typedef {Object} CloudStorageFilter
* @property {string} displayName Check if displayName contains this value
* @property {string} resourceName Check if resourceName contains this value
* @property {string} resource Check if resource name contains this value
* @property {module:API.cvat.enums.ProviderType} providerType Check if providerType equal this value
* @property {integer} id Check if id equals this value
* @property {integer} page Get specific page
......
// Copyright (C) 2021 Intel Corporation
// Copyright (C) 2021-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -174,13 +174,13 @@
},
/**
* Unique resource name
* @name resourceName
* @name resource
* @type {string}
* @memberof module:API.cvat.classes.CloudStorage
* @instance
* @throws {module:API.cvat.exceptions.ArgumentError}
*/
resourceName: {
resource: {
get: () => data.resource,
set: (value) => {
validateNotEmptyString(value);
......@@ -456,7 +456,7 @@
display_name: this.displayName,
credentials_type: this.credentialsType,
provider_type: this.providerType,
resource: this.resourceName,
resource: this.resource,
manifests: this.manifests,
};
......
// Copyright (C) 2019-2021 Intel Corporation
// Copyright (C) 2019-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -36,7 +36,7 @@
if (!(prop in fields)) {
throw new ArgumentError(`Unsupported filter property has been received: "${prop}"`);
} else if (!fields[prop](filter[prop])) {
throw new ArgumentError(`Received filter property "${prop}" is not satisfied for checker`);
throw new ArgumentError(`Received filter property "${prop}" does not satisfy API`);
}
}
}
......
// Copyright (C) 2021 Intel Corporation
// Copyright (C) 2021-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -360,7 +360,13 @@ Organization.prototype.deleteMembership.implementation = async function (members
Organization.prototype.leave.implementation = async function (user) {
checkObjectType('user', user, null, User);
if (typeof this.id === 'number') {
const result = await serverProxy.organizations.members(this.slug, 1, 10, { user: user.id });
const result = await serverProxy.organizations.members(this.slug, 1, 10, {
filter: JSON.stringify({
and: [{
'==': [{ var: 'user' }, user.id],
}],
}),
});
const [membership] = result.results;
if (!membership) {
throw new ServerError(`Could not find membership for user ${user.username} in organization ${this.slug}`);
......
......@@ -1891,7 +1891,7 @@
return '';
}
const frameData = await getPreview(this.taskId, this.jobID);
const frameData = await getPreview(this.taskId, this.id);
return frameData;
};
......
// Copyright (C) 2021 Intel Corporation
// Copyright (C) 2021-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -36,7 +36,7 @@ describe('Feature: get cloud storages', () => {
expect(cloudStorage.id).toBe(1);
expect(cloudStorage.providerType).toBe('AWS_S3_BUCKET');
expect(cloudStorage.credentialsType).toBe('KEY_SECRET_KEY_PAIR');
expect(cloudStorage.resourceName).toBe('bucket');
expect(cloudStorage.resource).toBe('bucket');
expect(cloudStorage.displayName).toBe('Demonstration bucket');
expect(cloudStorage.manifests).toHaveLength(1);
expect(cloudStorage.manifests[0]).toBe('manifest.jsonl');
......@@ -61,41 +61,18 @@ describe('Feature: get cloud storages', () => {
});
test('get cloud storages by filters', async () => {
const filters = [
new Map([
['providerType', 'AWS_S3_BUCKET'],
['resourceName', 'bucket'],
['displayName', 'Demonstration bucket'],
['credentialsType', 'KEY_SECRET_KEY_PAIR'],
['description', 'It is first bucket'],
]),
new Map([
['providerType', 'AZURE_CONTAINER'],
['resourceName', 'container'],
['displayName', 'Demonstration container'],
['credentialsType', 'ACCOUNT_NAME_TOKEN_PAIR'],
]),
new Map([
['providerType', 'GOOGLE_CLOUD_STORAGE'],
['resourceName', 'gcsbucket'],
['displayName', 'Demo GCS'],
['credentialsType', 'KEY_FILE_PATH'],
]),
];
const ids = [1, 2, 3];
await Promise.all(filters.map(async (_, idx) => {
const result = await window.cvat.cloudStorages.get(Object.fromEntries(filters[idx]));
const [cloudStorage] = result;
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(1);
expect(cloudStorage).toBeInstanceOf(CloudStorage);
expect(cloudStorage.id).toBe(ids[idx]);
filters[idx].forEach((value, key) => {
expect(cloudStorage[key]).toBe(value);
});
}));
const filter = {
and: [
{ '==': [{ var: 'display_name' }, 'Demonstration bucket'] },
{ '==': [{ var: 'resource_name' }, 'bucket'] },
{ '==': [{ var: 'description' }, 'It is first bucket'] },
{ '==': [{ var: 'provider_type' }, 'AWS_S3_BUCKET'] },
{ '==': [{ var: 'credentials_type' }, 'KEY_SECRET_KEY_PAIR'] },
],
};
const result = await window.cvat.cloudStorages.get({ filter: JSON.stringify(filter) });
expect(result).toBeInstanceOf(Array);
});
test('get cloud storage by invalid filters', async () => {
......
// Copyright (C) 2019-2021 Intel Corporation
// Copyright (C) 2019-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -54,16 +54,12 @@ describe('Feature: get projects', () => {
test('get projects by filters', async () => {
const result = await window.cvat.projects.get({
status: 'completed',
filter: '{"and":[{"==":[{"var":"status"},"completed"]}]}',
});
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Project);
expect(result[0].id).toBe(2);
expect(result[0].status).toBe('completed');
expect(result).toBeInstanceOf(Array);
});
test('get projects by invalid filters', async () => {
test('get projects by invalid query', async () => {
expect(
window.cvat.projects.get({
unknown: '5',
......
// Copyright (C) 2020-2021 Intel Corporation
// Copyright (C) 2020-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -52,39 +52,18 @@ describe('Feature: get a list of tasks', () => {
test('get tasks by filters', async () => {
const result = await window.cvat.tasks.get({
mode: 'interpolation',
filter: '{"and":[{"==":[{"var":"filter"},"interpolation"]}]}',
});
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(3);
for (const el of result) {
expect(el).toBeInstanceOf(Task);
expect(el.mode).toBe('interpolation');
}
expect(result).toBeInstanceOf(Array);
});
test('get tasks by invalid filters', async () => {
test('get tasks by invalid query', async () => {
expect(
window.cvat.tasks.get({
unknown: '5',
}),
).rejects.toThrow(window.cvat.exceptions.ArgumentError);
});
test('get task by name, status and mode', async () => {
const result = await window.cvat.tasks.get({
mode: 'interpolation',
status: 'annotation',
name: 'Test Task',
});
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(1);
for (const el of result) {
expect(el).toBeInstanceOf(Task);
expect(el.mode).toBe('interpolation');
expect(el.status).toBe('annotation');
expect(el.name).toBe('Test Task');
}
});
});
describe('Feature: save a task', () => {
......
{
"name": "cvat-ui",
"version": "1.35.2",
"version": "1.36.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "cvat-ui",
"version": "1.35.2",
"version": "1.36.0",
"license": "MIT",
"dependencies": {
"@ant-design/icons": "^4.6.3",
......@@ -45,6 +45,7 @@
"react-router": "^5.1.0",
"react-router-dom": "^5.1.0",
"react-share": "^4.4.0",
"react-sortable-hoc": "^2.0.0",
"redux": "^4.1.1",
"redux-devtools-extension": "^2.13.9",
"redux-logger": "^3.0.6",
......@@ -53,16 +54,17 @@
"devDependencies": {}
},
"../cvat-canvas": {
"version": "2.8.0",
"version": "2.13.1",
"license": "MIT",
"dependencies": {
"@types/polylabel": "^1.0.5",
"polylabel": "^1.1.0",
"svg.draggable.js": "2.2.2",
"svg.draw.js": "^2.0.4",
"svg.js": "2.7.1",
"svg.resize.js": "1.4.3",
"svg.select.js": "3.0.1"
},
"devDependencies": {}
}
},
"../cvat-canvas3d": {
"version": "0.0.1",
......@@ -75,13 +77,13 @@
"devDependencies": {}
},
"../cvat-core": {
"version": "3.16.1",
"version": "4.2.1",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
"browser-or-node": "^1.2.1",
"cvat-data": "../cvat-data",
"detect-browser": "^5.2.0",
"detect-browser": "^5.2.1",
"error-stack-parser": "^2.0.2",
"form-data": "^2.5.0",
"jest-config": "^26.6.3",
......@@ -90,7 +92,7 @@
"platform": "^1.3.5",
"quickhull": "^1.0.3",
"store": "^2.0.12",
"worker-loader": "^2.0.0"
"tus-js-client": "^2.3.0"
},
"devDependencies": {
"coveralls": "^3.0.5",
......@@ -4694,6 +4696,21 @@
"react": "^16.3.0 || ^17"
}
},
"node_modules/react-sortable-hoc": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz",
"integrity": "sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg==",
"dependencies": {
"@babel/runtime": "^7.2.0",
"invariant": "^2.2.4",
"prop-types": "^15.5.7"
},
"peerDependencies": {
"prop-types": "^15.5.7",
"react": "^16.3.0 || ^17.0.0",
"react-dom": "^16.3.0 || ^17.0.0"
}
},
"node_modules/reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
......@@ -7939,6 +7956,8 @@
"cvat-canvas": {
"version": "file:../cvat-canvas",
"requires": {
"@types/polylabel": "^1.0.5",
"polylabel": "^1.1.0",
"svg.draggable.js": "2.2.2",
"svg.draw.js": "^2.0.4",
"svg.js": "2.7.1",
......@@ -7961,7 +7980,7 @@
"browser-or-node": "^1.2.1",
"coveralls": "^3.0.5",
"cvat-data": "../cvat-data",
"detect-browser": "^5.2.0",
"detect-browser": "^5.2.1",
"error-stack-parser": "^2.0.2",
"form-data": "^2.5.0",
"jest": "^26.6.3",
......@@ -7973,7 +7992,7 @@
"platform": "^1.3.5",
"quickhull": "^1.0.3",
"store": "^2.0.12",
"worker-loader": "^2.0.0"
"tus-js-client": "^2.3.0"
}
},
"cyclist": {
......@@ -9880,6 +9899,16 @@
"jsonp": "^0.2.1"
}
},
"react-sortable-hoc": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz",
"integrity": "sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg==",
"requires": {
"@babel/runtime": "^7.2.0",
"invariant": "^2.2.4",
"prop-types": "^15.5.7"
}
},
"reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
......
{
"name": "cvat-ui",
"version": "1.35.2",
"version": "1.36.0",
"description": "CVAT single-page application",
"main": "src/index.tsx",
"scripts": {
......@@ -19,7 +19,6 @@
],
"author": "Intel",
"license": "MIT",
"devDependencies": {},
"dependencies": {
"@ant-design/icons": "^4.6.3",
"@types/lodash": "^4.14.172",
......@@ -57,6 +56,7 @@
"react-router": "^5.1.0",
"react-router-dom": "^5.1.0",
"react-share": "^4.4.0",
"react-sortable-hoc": "^2.0.0",
"redux": "^4.1.1",
"redux-devtools-extension": "^2.13.9",
"redux-logger": "^3.0.6",
......
// Copyright (C) 2021 Intel Corporation
// Copyright (C) 2021-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -103,6 +103,13 @@ export type CloudStorageActions = ActionUnion<typeof cloudStoragesActions>;
export function getCloudStoragesAsync(query: Partial<CloudStoragesQuery>): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
function camelToSnake(str: string): string {
return (
str[0].toLowerCase() + str.slice(1, str.length)
.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
);
}
dispatch(cloudStoragesActions.getCloudStorages());
dispatch(cloudStoragesActions.updateCloudStoragesGettingQuery(query));
......@@ -113,6 +120,23 @@ export function getCloudStoragesAsync(query: Partial<CloudStoragesQuery>): Thunk
}
}
// Temporary hack to do not change UI currently for cloud storages
// Will be redesigned in a different PR
const filter = {
and: ['displayName', 'resource', 'description', 'owner', 'providerType', 'credentialsType'].reduce<object[]>((acc, filterField) => {
if (filterField in filteredQuery) {
acc.push({ '==': [{ var: camelToSnake(filterField) }, filteredQuery[filterField]] });
delete filteredQuery[filterField];
}
return acc;
}, []),
};
if (filter.and.length) {
filteredQuery.filter = JSON.stringify(filter);
}
let result = null;
try {
result = await cvat.cloudStorages.get(filteredQuery);
......
......@@ -32,11 +32,10 @@ export const getJobsAsync = (query: JobsQuery): ThunkAction => async (dispatch)
try {
// Remove all keys with null values from the query
const filteredQuery: Partial<JobsQuery> = { ...query };
for (const [key, value] of Object.entries(filteredQuery)) {
if (value === null) {
delete filteredQuery[key];
}
}
if (filteredQuery.page === null) delete filteredQuery.page;
if (filteredQuery.filter === null) delete filteredQuery.filter;
if (filteredQuery.sort === null) delete filteredQuery.sort;
if (filteredQuery.search === null) delete filteredQuery.search;
dispatch(jobsActions.getJobs(filteredQuery));
const jobs = await cvat.jobs.get(filteredQuery);
......
// Copyright (C) 2019-2021 Intel Corporation
// Copyright (C) 2019-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -113,6 +113,23 @@ export function getProjectsAsync(
}
}
// Temporary hack to do not change UI currently for projects
// Will be redesigned in a different PR
const filter = {
and: ['owner', 'assignee', 'name', 'status'].reduce<object[]>((acc, filterField) => {
if (filterField in filteredQuery) {
acc.push({ '==': [{ var: filterField }, filteredQuery[filterField]] });
delete filteredQuery[filterField];
}
return acc;
}, []),
};
if (filter.and.length) {
filteredQuery.filter = JSON.stringify(filter);
}
let result = null;
try {
result = await cvat.projects.get(filteredQuery);
......
// Copyright (C) 2019-2021 Intel Corporation
// Copyright (C) 2019-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -86,6 +86,23 @@ export function getTasksAsync(query: TasksQuery): ThunkAction<Promise<void>, {},
}
}
// Temporary hack to do not change UI currently for tasks
// Will be redesigned in a different PR
const filter = {
and: ['owner', 'assignee', 'name', 'status', 'mode', 'dimension'].reduce<object[]>((acc, filterField) => {
if (filterField in filteredQuery) {
acc.push({ '==': [{ var: filterField }, filteredQuery[filterField]] });
delete filteredQuery[filterField];
}
return acc;
}, []),
};
if (filter.and.length) {
filteredQuery.filter = JSON.stringify(filter);
}
let result = null;
try {
result = await cvat.tasks.get(filteredQuery);
......
// Copyright (C) 2021 Intel Corporation
// Copyright (C) 2021-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -95,7 +95,7 @@ export default function CreateCloudStorageForm(props: Props): JSX.Element {
display_name: cloudStorage.displayName,
description: cloudStorage.description,
provider_type: cloudStorage.providerType,
resource: cloudStorage.resourceName,
resource: cloudStorage.resource,
manifests: manifestNames,
};
......
......@@ -5,7 +5,7 @@
import './styles.scss';
import React from 'react';
import { connect } from 'react-redux';
import { useHistory } from 'react-router';
import { useHistory, useLocation } from 'react-router';
import { Row, Col } from 'antd/lib/grid';
import Icon, {
SettingOutlined,
......@@ -167,6 +167,7 @@ function HeaderContainer(props: Props): JSX.Element {
} = consts;
const history = useHistory();
const location = useLocation();
function showAboutModal(): void {
Modal.info({
......@@ -370,12 +371,18 @@ function HeaderContainer(props: Props): JSX.Element {
</Menu>
);
const getButtonClassName = (value: string): string => {
// eslint-disable-next-line security/detect-non-literal-regexp
const regex = new RegExp(`${value}$`);
return location.pathname.match(regex) ? 'cvat-header-button cvat-active-header-button' : 'cvat-header-button';
};
return (
<Layout.Header className='cvat-header'>
<div className='cvat-left-header'>
<Icon className='cvat-logo-icon' component={CVATLogo} />
<Button
className='cvat-header-button'
className={getButtonClassName('projects')}
type='link'
value='projects'
href='/projects?page=1'
......@@ -387,7 +394,7 @@ function HeaderContainer(props: Props): JSX.Element {
Projects
</Button>
<Button
className='cvat-header-button'
className={getButtonClassName('tasks')}
type='link'
value='tasks'
href='/tasks?page=1'
......@@ -399,7 +406,7 @@ function HeaderContainer(props: Props): JSX.Element {
Tasks
</Button>
<Button
className='cvat-header-button'
className={getButtonClassName('jobs')}
type='link'
value='jobs'
href='/jobs?page=1'
......@@ -411,7 +418,7 @@ function HeaderContainer(props: Props): JSX.Element {
Jobs
</Button>
<Button
className='cvat-header-button'
className={getButtonClassName('cloudstorages')}
type='link'
value='cloudstorages'
href='/cloudstorages?page=1'
......@@ -422,9 +429,9 @@ function HeaderContainer(props: Props): JSX.Element {
>
Cloud Storages
</Button>
{isModelsPluginActive && (
{isModelsPluginActive ? (
<Button
className='cvat-header-button'
className={getButtonClassName('models')}
type='link'
value='models'
href='/models'
......@@ -435,8 +442,8 @@ function HeaderContainer(props: Props): JSX.Element {
>
Models
</Button>
)}
{isAnalyticsPluginActive && (
) : null}
{isAnalyticsPluginActive ? (
<Button
className='cvat-header-button'
type='link'
......@@ -450,7 +457,7 @@ function HeaderContainer(props: Props): JSX.Element {
>
Analytics
</Button>
)}
) : null}
</div>
<div className='cvat-right-header'>
<CVATTooltip overlay='Click to open repository'>
......
......@@ -13,7 +13,22 @@
background: $header-color;
}
.ant-btn.cvat-header-button {
color: $text-color;
padding: 0 $grid-unit-size;
margin-right: $grid-unit-size;
}
.cvat-left-header {
.ant-btn.cvat-header-button {
opacity: 0.7;
&.cvat-active-header-button {
font-weight: bold;
opacity: 1;
}
}
width: 50%;
display: flex;
justify-content: flex-start;
......@@ -40,12 +55,6 @@
}
}
.ant-btn.cvat-header-button {
color: $text-color;
padding: 0 $grid-unit-size;
margin-right: $grid-unit-size;
}
.cvat-header-menu-user-dropdown {
display: flex;
align-items: center;
......
// Copyright (C) 2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useState, useEffect } from 'react';
import 'react-awesome-query-builder/lib/css/styles.css';
import AntdConfig from 'react-awesome-query-builder/lib/config/antd';
import {
Builder, Config, ImmutableTree, Query, Utils as QbUtils,
} from 'react-awesome-query-builder';
import {
DownOutlined, FilterFilled, FilterOutlined,
} from '@ant-design/icons';
import Dropdown from 'antd/lib/dropdown';
import Space from 'antd/lib/space';
import Button from 'antd/lib/button';
import { useSelector } from 'react-redux';
import { CombinedState } from 'reducers/interfaces';
import Checkbox, { CheckboxChangeEvent } from 'antd/lib/checkbox/Checkbox';
import Menu from 'antd/lib/menu';
interface ResourceFilterProps {
predefinedVisible: boolean;
recentVisible: boolean;
builderVisible: boolean;
onPredefinedVisibleChange(visible: boolean): void;
onBuilderVisibleChange(visible: boolean): void;
onRecentVisibleChange(visible: boolean): void;
onApplyFilter(filter: string | null): void;
}
export default function ResourceFilterHOC(
filtrationCfg: Partial<Config>,
localStorageRecentKeyword: string,
localStorageRecentCapacity: number,
predefinedFilterValues: Record<string, string>,
defaultEnabledFilters: string[],
): React.FunctionComponent<ResourceFilterProps> {
const config: Config = { ...AntdConfig, ...filtrationCfg };
const defaultTree = QbUtils.checkTree(
QbUtils.loadTree({ id: QbUtils.uuid(), type: 'group' }), config,
) as ImmutableTree;
function keepFilterInLocalStorage(filter: string): void {
if (typeof filter !== 'string') {
return;
}
let savedItems: string[] = [];
try {
savedItems = JSON.parse(localStorage.getItem(localStorageRecentKeyword) || '[]');
if (!Array.isArray(savedItems) || savedItems.some((item: any) => typeof item !== 'string')) {
throw new Error('Wrong filters value stored');
}
} catch (_: any) {
// nothing to do
}
savedItems.splice(0, 0, filter);
savedItems = Array.from(new Set(savedItems)).slice(0, localStorageRecentCapacity);
localStorage.setItem(localStorageRecentKeyword, JSON.stringify(savedItems));
}
function receiveRecentFilters(): Record<string, string> {
let recentFilters: string[] = [];
try {
recentFilters = JSON.parse(localStorage.getItem(localStorageRecentKeyword) || '[]');
if (!Array.isArray(recentFilters) || recentFilters.some((item: any) => typeof item !== 'string')) {
throw new Error('Wrong filters value stored');
}
} catch (_: any) {
// nothing to do
}
return recentFilters
.reduce((acc: Record<string, string>, val: string) => ({ ...acc, [val]: val }), {});
}
const defaultAppliedFilter: {
predefined: string[] | null;
recent: string | null;
built: string | null;
} = {
predefined: null,
recent: null,
built: null,
};
function ResourceFilterComponent(props: ResourceFilterProps): JSX.Element {
const {
predefinedVisible, builderVisible, recentVisible,
onPredefinedVisibleChange, onBuilderVisibleChange, onRecentVisibleChange, onApplyFilter,
} = props;
const user = useSelector((state: CombinedState) => state.auth.user);
const [isMounted, setIsMounted] = useState<boolean>(false);
const [recentFilters, setRecentFilters] = useState<Record<string, string>>({});
const [predefinedFilters, setPredefinedFilters] = useState<Record<string, string>>({});
const [appliedFilter, setAppliedFilter] = useState<typeof defaultAppliedFilter>(defaultAppliedFilter);
const [state, setState] = useState<ImmutableTree>(defaultTree);
useEffect(() => {
setRecentFilters(receiveRecentFilters());
setIsMounted(true);
}, []);
useEffect(() => {
if (user) {
const result: Record<string, string> = {};
for (const key of Object.keys(predefinedFilterValues)) {
result[key] = predefinedFilterValues[key].replace('<username>', `${user.username}`);
}
setPredefinedFilters(result);
setAppliedFilter({
...appliedFilter,
predefined: defaultEnabledFilters
.filter((filterKey: string) => filterKey in result)
.map((filterKey: string) => result[filterKey]),
});
}
}, [user]);
useEffect(() => {
function unite(filters: string[]): string {
if (filters.length > 1) {
return JSON.stringify({
and: filters.map((filter: string): JSON => JSON.parse(filter)),
});
}
return filters[0];
}
function isValidTree(tree: ImmutableTree): boolean {
return (QbUtils.queryString(tree, config) || '').trim().length > 0 && QbUtils.isValidTree(tree);
}
if (!isMounted) {
// do not request jobs before until on mount hook is done
return;
}
if (appliedFilter.predefined?.length) {
onApplyFilter(unite(appliedFilter.predefined));
} else if (appliedFilter.recent) {
onApplyFilter(appliedFilter.recent);
const tree = QbUtils.loadFromJsonLogic(JSON.parse(appliedFilter.recent), config);
if (isValidTree(tree)) {
setState(tree);
}
} else if (appliedFilter.built) {
onApplyFilter(appliedFilter.built);
} else {
onApplyFilter(null);
setState(defaultTree);
}
}, [appliedFilter]);
const renderBuilder = (builderProps: any): JSX.Element => (
<div className='query-builder-container'>
<div className='query-builder qb-lite'>
<Builder {...builderProps} />
</div>
</div>
);
return (
<div className='cvat-jobs-page-filters'>
<Dropdown
destroyPopupOnHide
visible={predefinedVisible}
placement='bottomLeft'
overlay={(
<div className='cvat-jobs-page-predefined-filters-list'>
{Object.keys(predefinedFilters).map((key: string): JSX.Element => (
<Checkbox
checked={appliedFilter.predefined?.includes(predefinedFilters[key])}
onChange={(event: CheckboxChangeEvent) => {
let updatedValue: string[] | null = appliedFilter.predefined || [];
if (event.target.checked) {
updatedValue.push(predefinedFilters[key]);
} else {
updatedValue = updatedValue
.filter((appliedValue: string) => (
appliedValue !== predefinedFilters[key]
));
}
if (!updatedValue.length) {
updatedValue = null;
}
setAppliedFilter({
...defaultAppliedFilter,
predefined: updatedValue,
});
}}
key={key}
>
{key}
</Checkbox>
)) }
</div>
)}
>
<Button type='default' onClick={() => onPredefinedVisibleChange(!predefinedVisible)}>
Quick filters
{ appliedFilter.predefined ?
<FilterFilled /> :
<FilterOutlined />}
</Button>
</Dropdown>
<Dropdown
placement='bottomRight'
visible={builderVisible}
destroyPopupOnHide
overlay={(
<div className='cvat-jobs-page-filters-builder'>
{ Object.keys(recentFilters).length ? (
<Dropdown
placement='bottomRight'
visible={recentVisible}
destroyPopupOnHide
overlay={(
<div className='cvat-jobs-page-recent-filters-list'>
<Menu selectable={false}>
{Object.keys(recentFilters).map((key: string): JSX.Element | null => {
const tree = QbUtils.loadFromJsonLogic(JSON.parse(key), config);
if (!tree) {
return null;
}
return (
<Menu.Item
key={key}
onClick={() => {
if (appliedFilter.recent === key) {
setAppliedFilter(defaultAppliedFilter);
} else {
setAppliedFilter({
...defaultAppliedFilter,
recent: key,
});
}
}}
>
{QbUtils.queryString(tree, config)}
</Menu.Item>
);
})}
</Menu>
</div>
)}
>
<Button
size='small'
type='text'
onClick={
() => onRecentVisibleChange(!recentVisible)
}
>
Recent
<DownOutlined />
</Button>
</Dropdown>
) : null}
<Query
{...config}
onChange={(tree: ImmutableTree) => {
setState(tree);
}}
value={state}
renderBuilder={renderBuilder}
/>
<Space className='cvat-jobs-page-filters-space'>
<Button
disabled={!QbUtils.queryString(state, config)}
size='small'
onClick={() => {
setState(defaultTree);
setAppliedFilter({
...appliedFilter,
recent: null,
built: null,
});
}}
>
Reset
</Button>
<Button
size='small'
type='primary'
onClick={() => {
const filter = QbUtils.jsonLogicFormat(state, config).logic;
const stringified = JSON.stringify(filter);
keepFilterInLocalStorage(stringified);
setRecentFilters(receiveRecentFilters());
onBuilderVisibleChange(false);
setAppliedFilter({
predefined: null,
recent: null,
built: stringified,
});
}}
>
Apply
</Button>
</Space>
</div>
)}
>
<Button type='default' onClick={() => onBuilderVisibleChange(!builderVisible)}>
Filter
{ appliedFilter.built || appliedFilter.recent ?
<FilterFilled /> :
<FilterOutlined />}
</Button>
</Dropdown>
<Button
disabled={!(appliedFilter.built || appliedFilter.predefined || appliedFilter.recent)}
size='small'
type='link'
onClick={() => { setAppliedFilter({ ...defaultAppliedFilter }); }}
>
Clear filters
</Button>
</div>
);
}
return React.memo(ResourceFilterComponent);
}
......@@ -32,8 +32,14 @@ function JobCardComponent(props: Props): JSX.Element {
const [expanded, setExpanded] = useState<boolean>(false);
const history = useHistory();
const height = useCardHeight();
const onClick = (): void => {
history.push(`/tasks/${job.taskId}/jobs/${job.id}`);
const onClick = (event: React.MouseEvent): void => {
const url = `/tasks/${job.taskId}/jobs/${job.id}`;
if (event.ctrlKey) {
// eslint-disable-next-line security/detect-non-literal-fs-filename
window.open(url, '_blank', 'noopener noreferrer');
} else {
history.push(url);
}
};
return (
......
// Copyright (C) 2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { Config } from 'react-awesome-query-builder';
export const config: Partial<Config> = {
fields: {
state: {
label: 'State',
type: 'select',
operators: ['select_any_in', 'select_equals'], // ['select_equals', 'select_not_equals', 'select_any_in', 'select_not_any_in']
valueSources: ['value'],
fieldSettings: {
listValues: [
{ value: 'new', title: 'new' },
{ value: 'in progress', title: 'in progress' },
{ value: 'rejected', title: 'rejected' },
{ value: 'completed', title: 'completed' },
],
},
},
stage: {
label: 'Stage',
type: 'select',
operators: ['select_any_in', 'select_equals'],
valueSources: ['value'],
fieldSettings: {
listValues: [
{ value: 'annotation', title: 'annotation' },
{ value: 'validation', title: 'validation' },
{ value: 'acceptance', title: 'acceptance' },
],
},
},
dimension: {
label: 'Dimension',
type: 'select',
operators: ['select_equals'],
valueSources: ['value'],
fieldSettings: {
listValues: [
{ value: '2d', title: '2D' },
{ value: '3d', title: '3D' },
],
},
},
assignee: {
label: 'Assignee',
type: 'text', // todo: change to select
valueSources: ['value'],
fieldSettings: {
// useAsyncSearch: true,
// forceAsyncSearch: true,
// async fetch does not work for now in this library for AntdConfig
// but that issue was solved, see https://github.com/ukrbublik/react-awesome-query-builder/issues/616
// waiting for a new release, alternative is to use material design, but it is not the best option too
// asyncFetch: async (search: string | null) => {
// const users = await core.users.get({
// limit: 10,
// is_active: true,
// ...(search ? { search } : {}),
// });
// return {
// values: users.map((user: any) => ({
// value: user.username, title: user.username,
// })),
// hasMore: false,
// };
// },
},
},
updated_date: {
label: 'Last updated',
type: 'datetime',
operators: ['between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
},
id: {
label: 'ID',
type: 'number',
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
fieldSettings: { min: 0 },
valueSources: ['value'],
},
task_id: {
label: 'Task ID',
type: 'number',
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
fieldSettings: { min: 0 },
valueSources: ['value'],
},
project_id: {
label: 'Project ID',
type: 'number',
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
fieldSettings: { min: 0 },
valueSources: ['value'],
},
task_name: {
label: 'Task name',
type: 'text',
valueSources: ['value'],
operators: ['like'],
},
project_name: {
label: 'Project name',
type: 'text',
valueSources: ['value'],
operators: ['like'],
},
},
};
export const localStorageRecentCapacity = 10;
export const localStorageRecentKeyword = 'recentlyAppliedJobsFilters';
export const predefinedFilterValues = {
'Assigned to me': '{"and":[{"==":[{"var":"assignee"},"<username>"]}]}',
'Not completed': '{"!":{"or":[{"==":[{"var":"state"},"completed"]},{"==":[{"var":"stage"},"acceptance"]}]}}',
};
export const defaultEnabledFilters = ['Not completed'];
......@@ -3,8 +3,7 @@
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useEffect } from 'react';
import { useHistory } from 'react-router';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Spin from 'antd/lib/spin';
import { Col, Row } from 'antd/lib/grid';
......@@ -22,47 +21,6 @@ function JobsPageComponent(): JSX.Element {
const query = useSelector((state: CombinedState) => state.jobs.query);
const fetching = useSelector((state: CombinedState) => state.jobs.fetching);
const count = useSelector((state: CombinedState) => state.jobs.count);
const history = useHistory();
useEffect(() => {
// get relevant query parameters from the url and fetch jobs according to them
const { location } = history;
const searchParams = new URLSearchParams(location.search);
const copiedQuery = { ...query };
for (const key of Object.keys(copiedQuery)) {
if (searchParams.has(key)) {
const value = searchParams.get(key);
if (value) {
copiedQuery[key] = key === 'page' ? +value : value;
}
} else {
copiedQuery[key] = null;
}
}
dispatch(getJobsAsync(copiedQuery));
}, []);
useEffect(() => {
// when query is updated, set relevant search params to url
const searchParams = new URLSearchParams();
const { location } = history;
for (const [key, value] of Object.entries(query)) {
if (value) {
searchParams.set(key, value.toString());
}
}
history.push(`${location.pathname}?${searchParams.toString()}`);
}, [query]);
if (fetching) {
return (
<div className='cvat-jobs-page'>
<Spin size='large' className='cvat-spinner' />
</div>
);
}
const dimensions = {
md: 22,
......@@ -71,43 +29,65 @@ function JobsPageComponent(): JSX.Element {
xxl: 16,
};
const content = count ? (
<>
<JobsContentComponent />
<Row justify='space-around' about='middle'>
<Col {...dimensions}>
<Pagination
className='cvat-jobs-page-pagination'
onChange={(page: number) => {
dispatch(getJobsAsync({
...query,
page,
}));
}}
showSizeChanger={false}
total={count}
pageSize={12}
current={query.page}
showQuickJumper
/>
</Col>
</Row>
</>
) : <Empty />;
return (
<div className='cvat-jobs-page'>
<TopBarComponent
query={query}
onChangeFilters={(filters: Record<string, string | null>) => {
onApplySearch={(search: string | null) => {
dispatch(
getJobsAsync({
...query,
search,
page: 1,
}),
);
}}
onApplyFilter={(filter: string | null) => {
dispatch(
getJobsAsync({
...query,
...filters,
filter,
page: 1,
}),
);
}}
onApplySorting={(sorting: string | null) => {
dispatch(
getJobsAsync({
...query,
sort: sorting,
page: 1,
}),
);
}}
/>
{count ? (
<>
<JobsContentComponent />
<Row justify='space-around' about='middle'>
<Col {...dimensions}>
<Pagination
className='cvat-jobs-page-pagination'
onChange={(page: number) => {
dispatch(getJobsAsync({
...query,
page,
}));
}}
showSizeChanger={false}
total={count}
pageSize={12}
current={query.page}
showQuickJumper
/>
</Col>
</Row>
</>
) : <Empty />}
{ fetching ? (
<Spin size='large' className='cvat-spinner' />
) : content }
</div>
);
......
// Copyright (C) 2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useState, useEffect } from 'react';
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
import {
OrderedListOutlined, SortAscendingOutlined, SortDescendingOutlined,
} from '@ant-design/icons';
import Button from 'antd/lib/button';
import Dropdown from 'antd/lib/dropdown';
import Radio from 'antd/lib/radio';
import CVATTooltip from 'components/common/cvat-tooltip';
interface Props {
sortingFields: string[];
defaultFields: string[];
visible: boolean;
onVisibleChange(visible: boolean): void;
onApplySorting(sorting: string | null): void;
}
const ANCHOR_KEYWORD = '__anchor__';
const SortableItem = SortableElement(
({
value, appliedSorting, setAppliedSorting, valueIndex, anchorIndex,
}: {
value: string;
valueIndex: number;
anchorIndex: number;
appliedSorting: Record<string, string>;
setAppliedSorting: (arg: Record<string, string>) => void;
}): JSX.Element => {
const isActiveField = value in appliedSorting;
const isAscendingField = isActiveField && !appliedSorting[value]?.startsWith('-');
const isDescendingField = isActiveField && !isAscendingField;
const onClick = (): void => {
if (isDescendingField) {
setAppliedSorting({ ...appliedSorting, [value]: value });
} else if (isAscendingField) {
setAppliedSorting({ ...appliedSorting, [value]: `-${value}` });
}
};
if (value === ANCHOR_KEYWORD) {
return (
<hr className='cvat-sorting-anchor' />
);
}
return (
<div className='cvat-sorting-field'>
<Radio.Button disabled={valueIndex > anchorIndex}>{value}</Radio.Button>
<div>
<CVATTooltip overlay={appliedSorting[value]?.startsWith('-') ? 'Descending sort' : 'Ascending sort'}>
<Button type='text' disabled={!isActiveField} onClick={onClick}>
{
isDescendingField ? (
<SortDescendingOutlined />
) : (
<SortAscendingOutlined />
)
}
</Button>
</CVATTooltip>
</div>
</div>
);
},
);
const SortableList = SortableContainer(
({ items, appliedSorting, setAppliedSorting } :
{
items: string[];
appliedSorting: Record<string, string>;
setAppliedSorting: (arg: Record<string, string>) => void;
}) => (
<div className='cvat-jobs-page-sorting-list'>
{ items.map((value: string, index: number) => (
<SortableItem
key={`item-${value}`}
appliedSorting={appliedSorting}
setAppliedSorting={setAppliedSorting}
index={index}
value={value}
valueIndex={index}
anchorIndex={items.indexOf(ANCHOR_KEYWORD)}
/>
)) }
</div>
),
);
function SortingModalComponent(props: Props): JSX.Element {
const {
sortingFields: sortingFieldsProp,
defaultFields, visible, onApplySorting, onVisibleChange,
} = props;
const [appliedSorting, setAppliedSorting] = useState<Record<string, string>>(
defaultFields.reduce((acc: Record<string, string>, field: string) => {
const [isAscending, absField] = field.startsWith('-') ?
[false, field.slice(1).replace('_', ' ')] : [true, field.replace('_', ' ')];
const originalField = sortingFieldsProp.find((el: string) => el.toLowerCase() === absField.toLowerCase());
if (originalField) {
return { ...acc, [originalField]: isAscending ? originalField : `-${originalField}` };
}
return acc;
}, {}),
);
const [sortingFields, setSortingFields] = useState<string[]>(
Array.from(new Set([...Object.keys(appliedSorting), ANCHOR_KEYWORD, ...sortingFieldsProp])),
);
const [appliedOrder, setAppliedOrder] = useState<string[]>([...defaultFields]);
useEffect(() => {
const anchorIdx = sortingFields.indexOf(ANCHOR_KEYWORD);
const appliedSortingCopy = { ...appliedSorting };
const slicedSortingFields = sortingFields.slice(0, anchorIdx);
const updated = slicedSortingFields.length !== appliedOrder.length || slicedSortingFields
.some((field: string, index: number) => field !== appliedOrder[index]);
sortingFields.forEach((field: string, index: number) => {
if (index < anchorIdx && !(field in appliedSortingCopy)) {
appliedSortingCopy[field] = field;
} else if (index >= anchorIdx && field in appliedSortingCopy) {
delete appliedSortingCopy[field];
}
});
if (updated) {
setAppliedOrder(slicedSortingFields);
setAppliedSorting(appliedSortingCopy);
}
}, [sortingFields]);
useEffect(() => {
// this hook uses sortingFields to understand order
// but we do not specify this field in dependencies
// because we do not want the hook to be called after changing sortingField
// sortingField value is always relevant because if order changes, the hook before will be called first
const anchorIdx = sortingFields.indexOf(ANCHOR_KEYWORD);
const sortingString = sortingFields.slice(0, anchorIdx)
.map((field: string): string => appliedSorting[field])
.join(',').toLowerCase().replace(/\s/g, '_');
onApplySorting(sortingString || null);
}, [appliedSorting]);
return (
<Dropdown
destroyPopupOnHide
visible={visible}
placement='bottomLeft'
overlay={(
<SortableList
onSortEnd={({ oldIndex, newIndex }: { oldIndex: number, newIndex: number }) => {
if (oldIndex !== newIndex) {
const sortingFieldsCopy = [...sortingFields];
sortingFieldsCopy.splice(newIndex, 0, ...sortingFieldsCopy.splice(oldIndex, 1));
setSortingFields(sortingFieldsCopy);
}
}}
helperClass='cvat-sorting-dragged-item'
items={sortingFields}
appliedSorting={appliedSorting}
setAppliedSorting={setAppliedSorting}
/>
)}
>
<Button type='default' onClick={() => onVisibleChange(!visible)}>
Sort by
<OrderedListOutlined />
</Button>
</Dropdown>
);
}
export default React.memo(SortingModalComponent);
......@@ -10,22 +10,6 @@
height: 100%;
width: 100%;
.cvat-jobs-page-top-bar {
> div:nth-child(1) {
> div:nth-child(1) {
width: 100%;
> div:nth-child(1) {
display: flex;
span {
margin-right: $grid-unit-size;
}
}
}
}
}
> div:nth-child(1) {
div > {
.cvat-title {
......@@ -128,19 +112,158 @@
right: $grid-unit-size;
font-size: 16px;
}
}
.cvat-jobs-filter-dropdown-users {
padding: $grid-unit-size;
}
.cvat-jobs-page-filters {
display: flex;
align-items: center;
span[aria-label=down] {
margin-right: $grid-unit-size;
}
> button {
margin-right: $grid-unit-size;
.cvat-jobs-page-filters {
.ant-table-cell {
width: $grid-unit-size * 15;
background: #f0f2f5;
&:last-child {
margin-right: 0;
}
}
}
.ant-table-tbody {
display: none;
.cvat-jobs-page-recent-filters-list {
max-width: $grid-unit-size * 64;
.ant-menu {
border: none;
.ant-menu-item {
padding: $grid-unit-size;
margin: 0;
line-height: initial;
height: auto;
}
}
}
.cvat-jobs-filter-dropdown-users {
.cvat-jobs-page-filters-builder {
background: white;
padding: $grid-unit-size;
border-radius: 4px;
box-shadow: $box-shadow-base;
display: flex;
flex-direction: column;
align-items: flex-end;
// redefine default awesome react query builder styles below
.query-builder {
margin: $grid-unit-size;
.group.group-or-rule {
background: none !important;
border: none !important;
}
.group--actions.group--actions--tr {
opacity: 1 !important;
}
.group--conjunctions {
div.ant-btn-group {
button.ant-btn {
width: auto !important;
opacity: 1 !important;
margin-right: $grid-unit-size !important;
padding: 0 $grid-unit-size !important;
}
}
}
}
}
.cvat-jobs-page-sorting-list,
.cvat-jobs-page-predefined-filters-list,
.cvat-jobs-page-recent-filters-list {
background: white;
padding: $grid-unit-size;
border-radius: 4px;
display: flex;
flex-direction: column;
box-shadow: $box-shadow-base;
.ant-checkbox-wrapper {
margin-bottom: $grid-unit-size;
margin-left: 0;
}
}
.cvat-jobs-page-sorting-list {
width: $grid-unit-size * 24;
}
.cvat-sorting-field {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $grid-unit-size;
.ant-radio-button-wrapper {
width: $grid-unit-size * 16;
user-select: none;
cursor: move;
}
}
.cvat-sorting-anchor {
width: 100%;
pointer-events: none;
&:first-child {
margin-top: $grid-unit-size * 4;
}
&:last-child {
margin-bottom: $grid-unit-size * 4;
}
}
.cvat-sorting-dragged-item {
z-index: 10000;
}
.cvat-jobs-page-filters-space {
justify-content: right;
align-items: center;
display: flex;
}
.cvat-jobs-page-top-bar {
> div {
display: flex;
justify-content: space-between;
> div {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.cvat-jobs-page-search-bar {
width: $grid-unit-size * 32;
padding-left: $grid-unit-size * 0.5;
}
> div {
> *:not(:last-child) {
margin-right: $grid-unit-size;
}
display: flex;
}
}
}
}
......@@ -2,101 +2,88 @@
//
// SPDX-License-Identifier: MIT
import React from 'react';
import React, { useState } from 'react';
import { Col, Row } from 'antd/lib/grid';
import Text from 'antd/lib/typography/Text';
import Table from 'antd/lib/table';
import { FilterValue, TablePaginationConfig } from 'antd/lib/table/interface';
import Input from 'antd/lib/input';
import { JobsQuery } from 'reducers/interfaces';
import UserSelector, { User } from 'components/task-page/user-selector';
import Button from 'antd/lib/button';
import SortingComponent from './sorting';
import ResourceFilterHOC from './filtering';
import {
localStorageRecentKeyword, localStorageRecentCapacity,
predefinedFilterValues, defaultEnabledFilters, config,
} from './jobs-filter-configuration';
const FilteringComponent = ResourceFilterHOC(
config, localStorageRecentKeyword, localStorageRecentCapacity,
predefinedFilterValues, defaultEnabledFilters,
);
const defaultVisibility: {
predefined: boolean;
recent: boolean;
builder: boolean;
sorting: boolean;
} = {
predefined: false,
recent: false,
builder: false,
sorting: false,
};
interface Props {
onChangeFilters(filters: Record<string, string | null>): void;
query: JobsQuery;
onApplyFilter(filter: string | null): void;
onApplySorting(sorting: string | null): void;
onApplySearch(search: string | null): void;
}
function TopBarComponent(props: Props): JSX.Element {
const { query, onChangeFilters } = props;
const columns = [
{
title: 'Stage',
dataIndex: 'stage',
key: 'stage',
filteredValue: query.stage?.split(',') || null,
className: `${query.stage ? 'cvat-jobs-page-filter cvat-jobs-page-filter-active' : 'cvat-jobs-page-filter'}`,
filters: [
{ text: 'annotation', value: 'annotation' },
{ text: 'validation', value: 'validation' },
{ text: 'acceptance', value: 'acceptance' },
],
},
{
title: 'State',
dataIndex: 'state',
key: 'state',
filteredValue: query.state?.split(',') || null,
className: `${query.state ? 'cvat-jobs-page-filter cvat-jobs-page-filter-active' : 'cvat-jobs-page-filter'}`,
filters: [
{ text: 'new', value: 'new' },
{ text: 'in progress', value: 'in progress' },
{ text: 'completed', value: 'completed' },
{ text: 'rejected', value: 'rejected' },
],
},
{
title: 'Assignee',
dataIndex: 'assignee',
key: 'assignee',
filteredValue: query.assignee ? [query.assignee] : null,
className: `${query.assignee ? 'cvat-jobs-page-filter cvat-jobs-page-filter-active' : 'cvat-jobs-page-filter'}`,
filterDropdown: (
<div className='cvat-jobs-filter-dropdown-users'>
<UserSelector
username={query.assignee ? query.assignee : undefined}
value={null}
onSelect={(value: User | null): void => {
if (value) {
if (query.assignee !== value.username) {
onChangeFilters({ assignee: value.username });
}
} else if (query.assignee !== null) {
onChangeFilters({ assignee: null });
}
}}
/>
<Button disabled={query.assignee === null} type='link' onClick={() => onChangeFilters({ assignee: null })}>
Reset
</Button>
</div>
),
},
];
const {
query, onApplyFilter, onApplySorting, onApplySearch,
} = props;
const [visibility, setVisibility] = useState<typeof defaultVisibility>(defaultVisibility);
return (
<Row className='cvat-jobs-page-top-bar' justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={16}>
<Row justify='space-between' align='bottom'>
<Col>
<Text className='cvat-title'>Jobs</Text>
</Col>
<Table
onChange={(_: TablePaginationConfig, filters: Record<string, FilterValue | null>) => {
const processed = Object.fromEntries(
Object.entries(filters)
.map(([key, values]) => (
[key, typeof values === 'string' || values === null ? values : values.join(',')]
)),
);
onChangeFilters(processed);
<div>
<Input.Search
enterButton
onSearch={(phrase: string) => {
onApplySearch(phrase);
}}
className='cvat-jobs-page-filters'
columns={columns}
size='small'
defaultValue={query.search || ''}
className='cvat-jobs-page-search-bar'
placeholder='Search ..'
/>
</Row>
<div>
<SortingComponent
visible={visibility.sorting}
onVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, sorting: visible })
)}
defaultFields={query.sort?.split(',') || ['ID']}
sortingFields={['ID', 'Assignee', 'Updated date', 'Stage', 'State', 'Task ID', 'Project ID', 'Task name', 'Project name']}
onApplySorting={onApplySorting}
/>
<FilteringComponent
predefinedVisible={visibility.predefined}
builderVisible={visibility.builder}
recentVisible={visibility.recent}
onPredefinedVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, predefined: visible })
)}
onBuilderVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, builder: visible })
)}
onRecentVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, builder: visibility.builder, recent: visible })
)}
onApplyFilter={onApplyFilter}
/>
</div>
</div>
</Col>
</Row>
);
......
// Copyright (C) 2021 Intel Corporation
// Copyright (C) 2021-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -51,7 +51,7 @@ export default function SearchTooltip(props: Props): JSX.Element {
) : null}
{instance === 'cloudstorage' ? (
<Paragraph>
<Text strong>resourceName: mycvatbucket</Text>
<Text strong>resource: mycvatbucket</Text>
<Text>
all
{instances}
......
// Copyright (C) 2021 Intel Corporation
// Copyright (C) 2021-2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -20,7 +20,7 @@ const defaultState: CloudStoragesState = {
owner: null,
displayName: null,
description: null,
resourceName: null,
resource: null,
providerType: null,
credentialsType: null,
status: null,
......
......@@ -70,6 +70,7 @@ export interface TasksQuery {
name: string | null;
status: string | null;
mode: string | null;
filter: string | null;
projectId: number | null;
[key: string]: string | number | null;
}
......@@ -81,10 +82,9 @@ export interface Task {
export interface JobsQuery {
page: number;
assignee: string | null;
stage: 'annotation' | 'validation' | 'acceptance' | null;
state: 'new' | 'in progress' | 'rejected' | 'completed' | null;
[index: string]: number | null | string | undefined;
sort: string | null;
search: string | null;
filter: string | null;
}
export interface JobsState {
......@@ -159,7 +159,7 @@ export interface CloudStoragesQuery {
owner: string | null;
displayName: string | null;
description: string | null;
resourceName: string | null;
resource: string | null;
providerType: string | null;
credentialsType: string | null;
[key: string]: string | number | null | undefined;
......
......@@ -10,9 +10,8 @@ const defaultState: JobsState = {
count: 0,
query: {
page: 1,
state: null,
stage: null,
assignee: null,
filter: null,
sort: null,
},
current: [],
previews: [],
......
# Copyright (C) 2022 Intel Corporation
#
# SPDX-License-Identifier: MIT
from rest_framework import filters
from functools import reduce
import operator
import json
from django.db.models import Q
from rest_framework.compat import coreapi, coreschema
from django.utils.translation import gettext_lazy as _
from django.utils.encoding import force_str
from rest_framework.exceptions import ValidationError
class SearchFilter(filters.SearchFilter):
def get_search_fields(self, view, request):
search_fields = getattr(view, 'search_fields', [])
lookup_fields = {field:field for field in search_fields}
view_lookup_fields = getattr(view, 'lookup_fields', {})
keys_to_update = set(search_fields) & set(view_lookup_fields.keys())
for key in keys_to_update:
lookup_fields[key] = view_lookup_fields[key]
return lookup_fields.values()
def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
search_fields = getattr(view, 'search_fields', [])
full_description = self.search_description + \
f' Avaliable search_fields: {search_fields}'
return [
coreapi.Field(
name=self.search_param,
required=False,
location='query',
schema=coreschema.String(
title=force_str(self.search_title),
description=force_str(full_description)
)
)
]
def get_schema_operation_parameters(self, view):
search_fields = getattr(view, 'search_fields', [])
full_description = self.search_description + \
f' Avaliable search_fields: {search_fields}'
return [{
'name': self.search_param,
'required': False,
'in': 'query',
'description': force_str(full_description),
'schema': {
'type': 'string',
},
}]
class OrderingFilter(filters.OrderingFilter):
ordering_param = 'sort'
def get_ordering(self, request, queryset, view):
ordering = []
lookup_fields = self._get_lookup_fields(request, queryset, view)
for term in super().get_ordering(request, queryset, view):
flag = ''
if term.startswith("-"):
flag = '-'
term = term[1:]
ordering.append(flag + lookup_fields[term])
return ordering
def _get_lookup_fields(self, request, queryset, view):
ordering_fields = self.get_valid_fields(queryset, view, {'request': request})
lookup_fields = {field:field for field, _ in ordering_fields}
lookup_fields.update(getattr(view, 'lookup_fields', {}))
return lookup_fields
def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
ordering_fields = getattr(view, 'ordering_fields', [])
full_description = self.ordering_description + \
f' Avaliable ordering_fields: {ordering_fields}'
return [
coreapi.Field(
name=self.ordering_param,
required=False,
location='query',
schema=coreschema.String(
title=force_str(self.ordering_title),
description=force_str(full_description)
)
)
]
def get_schema_operation_parameters(self, view):
ordering_fields = getattr(view, 'ordering_fields', [])
full_description = self.ordering_description + \
f' Avaliable ordering_fields: {ordering_fields}'
return [{
'name': self.ordering_param,
'required': False,
'in': 'query',
'description': force_str(full_description),
'schema': {
'type': 'string',
},
}]
class JsonLogicFilter(filters.BaseFilterBackend):
filter_param = 'filter'
filter_title = _('Filter')
filter_description = _('A filter term.')
def _build_Q(self, rules, lookup_fields):
op, args = next(iter(rules.items()))
if op in ['or', 'and']:
return reduce({
'or': operator.or_,
'and': operator.and_
}[op], [self._build_Q(arg, lookup_fields) for arg in args])
elif op == '!':
return ~self._build_Q(args, lookup_fields)
elif op == '!!':
return self._build_Q(args, lookup_fields)
elif op == 'var':
return Q(**{args + '__isnull': False})
elif op in ['==', '<', '>', '<=', '>='] and len(args) == 2:
var = lookup_fields[args[0]['var']]
q_var = var + {
'==': '',
'<': '__lt',
'<=': '__lte',
'>': '__gt',
'>=': '__gte'
}[op]
return Q(**{q_var: args[1]})
elif op == 'in':
if isinstance(args[0], dict):
var = lookup_fields[args[0]['var']]
return Q(**{var + '__in': args[1]})
else:
var = lookup_fields[args[1]['var']]
return Q(**{var + '__contains': args[0]})
elif op == '<=' and len(args) == 3:
var = lookup_fields[args[1]['var']]
return Q(**{var + '__gte': args[0]}) & Q(**{var + '__lte': args[2]})
else:
raise ValidationError(f'filter: {op} operation with {args} arguments is not implemented')
def filter_queryset(self, request, queryset, view):
json_rules = request.query_params.get(self.filter_param)
if json_rules:
try:
rules = json.loads(json_rules)
if not len(rules):
raise ValidationError(f"filter shouldn't be empty")
except json.decoder.JSONDecodeError:
raise ValidationError(f'filter: Json syntax should be used')
lookup_fields = self._get_lookup_fields(request, view)
try:
q_object = self._build_Q(rules, lookup_fields)
except KeyError as ex:
raise ValidationError(f'filter: {str(ex)} term is not supported')
return queryset.filter(q_object)
return queryset
def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
filter_fields = getattr(view, 'filter_fields', [])
full_description = self.filter_description + \
f' Avaliable filter_fields: {filter_fields}'
return [
coreapi.Field(
name=self.filter_param,
required=False,
location='query',
schema=coreschema.String(
title=force_str(self.filter_title),
description=force_str(full_description)
)
)
]
def get_schema_operation_parameters(self, view):
filter_fields = getattr(view, 'filter_fields', [])
full_description = self.filter_description + \
f' Avaliable filter_fields: {filter_fields}'
return [
{
'name': self.filter_param,
'required': False,
'in': 'query',
'description': force_str(full_description),
'schema': {
'type': 'string',
},
},
]
def _get_lookup_fields(self, request, view):
filter_fields = getattr(view, 'filter_fields', [])
lookup_fields = {field:field for field in filter_fields}
lookup_fields.update(getattr(view, 'lookup_fields', {}))
return lookup_fields
......@@ -22,7 +22,6 @@ from django.contrib.auth.models import User
from django.db import IntegrityError
from django.http import HttpResponse, HttpResponseNotFound, HttpResponseBadRequest
from django.utils import timezone
from django_filters import rest_framework as filters
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
......@@ -48,9 +47,9 @@ from cvat.apps.engine.frame_provider import FrameProvider
from cvat.apps.engine.media_extractors import ImageListReader
from cvat.apps.engine.mime_types import mimetypes
from cvat.apps.engine.models import (
Job, StatusChoice, Task, Project, Issue, Data,
Job, Task, Project, Issue, Data,
Comment, StorageMethodChoice, StorageChoice, Image,
CredentialsTypeChoice, CloudProviderChoice
CloudProviderChoice
)
from cvat.apps.engine.models import CloudStorage as CloudStorageModel
from cvat.apps.engine.serializers import (
......@@ -221,31 +220,8 @@ class ServerViewSet(viewsets.ViewSet):
}
return Response(response)
class ProjectFilter(filters.FilterSet):
name = filters.CharFilter(field_name="name", lookup_expr="icontains")
owner = filters.CharFilter(field_name="owner__username", lookup_expr="icontains")
assignee = filters.CharFilter(field_name="assignee__username", lookup_expr="icontains")
status = filters.CharFilter(field_name="status", lookup_expr="icontains")
class Meta:
model = models.Project
fields = ("id", "name", "owner", "status")
@extend_schema_view(list=extend_schema(
summary='Returns a paginated list of projects according to query parameters (12 projects per page)',
parameters=[
OpenApiParameter('id', description='A unique number value identifying this project',
location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER),
OpenApiParameter('name', description='Find all projects where name contains a parameter value',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter('owner', description='Find all project where owner name contains a parameter value',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter('status', description='Find all projects with a specific status',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=StatusChoice.list()),
OpenApiParameter('names_only', description="Returns only names and id's of projects",
location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL)
],
responses={
'200': PolymorphicProxySerializer(component_name='PolymorphicProject',
serializers=[
......@@ -277,18 +253,19 @@ class ProjectViewSet(viewsets.ModelViewSet):
queryset=models.Label.objects.order_by('id')
))
search_fields = ("name", "owner__username", "assignee__username", "status")
filterset_class = ProjectFilter
ordering_fields = ("id", "name", "owner", "status", "assignee")
ordering = ("-id",)
# NOTE: The search_fields attribute should be a list of names of text
# type fields on the model,such as CharField or TextField
search_fields = ('name', 'owner', 'assignee', 'status')
filter_fields = list(search_fields) + ['id']
ordering_fields = filter_fields
ordering = "-id"
lookup_fields = {'owner': 'owner__username', 'assignee': 'assignee__username'}
http_method_names = ('get', 'post', 'head', 'patch', 'delete')
iam_organization_field = 'organization'
def get_serializer_class(self):
if self.request.path.endswith('tasks'):
return TaskSerializer
if self.request.query_params and self.request.query_params.get("names_only") == "true":
return ProjectSearchSerializer
else:
return ProjectSerializer
......@@ -550,35 +527,8 @@ class DataChunkGetter:
return Response(data='unknown data type {}.'.format(self.type),
status=status.HTTP_400_BAD_REQUEST)
class TaskFilter(filters.FilterSet):
project = filters.CharFilter(field_name="project__name", lookup_expr="icontains")
name = filters.CharFilter(field_name="name", lookup_expr="icontains")
owner = filters.CharFilter(field_name="owner__username", lookup_expr="icontains")
mode = filters.CharFilter(field_name="mode", lookup_expr="icontains")
status = filters.CharFilter(field_name="status", lookup_expr="icontains")
assignee = filters.CharFilter(field_name="assignee__username", lookup_expr="icontains")
class Meta:
model = Task
fields = ("id", "project_id", "project", "name", "owner", "mode", "status",
"assignee")
@extend_schema_view(list=extend_schema(
summary='Returns a paginated list of tasks according to query parameters (10 tasks per page)',
parameters=[
OpenApiParameter('id', description='A unique number value identifying this task',
location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER),
OpenApiParameter('name', description='Find all tasks where name contains a parameter value',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter('owner', description='Find all tasks where owner name contains a parameter value',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter('mode', description='Find all tasks with a specific mode',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=['annotation', 'interpolation']),
OpenApiParameter('status', description='Find all tasks with a specific status',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=StatusChoice.list()),
OpenApiParameter('assignee', description='Find all tasks where assignee name contains a parameter value',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR)
],
responses={
'200': TaskSerializer(many=True),
}, tags=['tasks'], versions=['2.0']))
......@@ -609,12 +559,13 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet):
queryset = Task.objects.prefetch_related(
Prefetch('label_set', queryset=models.Label.objects.order_by('id')),
"label_set__attributespec_set",
"segment_set__job_set",
).order_by('-id')
"segment_set__job_set")
serializer_class = TaskSerializer
search_fields = ("name", "owner__username", "mode", "status")
filterset_class = TaskFilter
ordering_fields = ("id", "name", "owner", "status", "assignee", "subset")
lookup_fields = {'project_name': 'project__name', 'owner': 'owner__username', 'assignee': 'assignee__username'}
search_fields = ('project_name', 'name', 'owner', 'status', 'assignee', 'subset', 'mode', 'dimension')
filter_fields = list(search_fields) + ['id', 'project_id']
ordering_fields = filter_fields
ordering = "-id"
iam_organization_field = 'organization'
def get_queryset(self):
......@@ -956,18 +907,6 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet):
filename=request.query_params.get("filename", "").lower(),
)
class CharInFilter(filters.BaseInFilter, filters.CharFilter):
pass
class JobFilter(filters.FilterSet):
assignee = filters.CharFilter(field_name="assignee__username", lookup_expr="icontains")
stage = CharInFilter(field_name="stage", lookup_expr="in")
state = CharInFilter(field_name="state", lookup_expr="in")
class Meta:
model = Job
fields = ("assignee", )
@extend_schema_view(retrieve=extend_schema(
summary='Method returns details of a job',
responses={
......@@ -990,12 +929,25 @@ class JobFilter(filters.FilterSet):
}, tags=['jobs'], versions=['2.0']))
class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.UpdateModelMixin):
queryset = Job.objects.all().order_by('id')
filterset_class = JobFilter
queryset = Job.objects.all()
iam_organization_field = 'segment__task__organization'
search_fields = ('task_name', 'project_name', 'assignee', 'state', 'stage')
filter_fields = list(search_fields) + ['id', 'task_id', 'project_id', 'updated_date']
ordering_fields = filter_fields
ordering = "-id"
lookup_fields = {
'dimension': 'segment__task__dimension',
'task_id': 'segment__task_id',
'project_id': 'segment__task__project_id',
'task_name': 'segment__task__name',
'project_name': 'segment__task__project__name',
'updated_date': 'segment__task__updated_date',
'assignee': 'assignee__username'
}
def get_queryset(self):
queryset = super().get_queryset()
if self.action == 'list':
perm = JobPermission.create_list(self.request)
queryset = perm.filter(queryset)
......@@ -1039,7 +991,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
data = dm.task.get_job_data(pk)
return Response(data)
elif request.method == 'PUT':
format_name = request.query_params.get("format", "")
format_name = request.query_params.get('format', '')
if format_name:
return _import_annotations(
request=request,
......@@ -1147,6 +1099,16 @@ class IssueViewSet(viewsets.ModelViewSet):
queryset = Issue.objects.all().order_by('-id')
http_method_names = ['get', 'post', 'patch', 'delete', 'options']
iam_organization_field = 'job__segment__task__organization'
search_fields = ('owner', 'assignee')
filter_fields = list(search_fields) + ['id', 'job_id', 'task_id', 'resolved']
lookup_fields = {
'owner': 'owner__username',
'assignee': 'assignee__username',
'job_id': 'job__id',
'task_id': 'job__segment__task__id',
}
ordering_fields = filter_fields
ordering = '-id'
def get_queryset(self):
queryset = super().get_queryset()
......@@ -1212,6 +1174,11 @@ class CommentViewSet(viewsets.ModelViewSet):
queryset = Comment.objects.all().order_by('-id')
http_method_names = ['get', 'post', 'patch', 'delete', 'options']
iam_organization_field = 'issue__job__segment__task__organization'
search_fields = ('owner',)
filter_fields = list(search_fields) + ['id', 'issue_id']
ordering_fields = filter_fields
ordering = '-id'
lookup_fields = {'owner': 'owner__username', 'issue_id': 'issue__id'}
def get_queryset(self):
queryset = super().get_queryset()
......@@ -1230,19 +1197,8 @@ class CommentViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
class UserFilter(filters.FilterSet):
class Meta:
model = User
fields = ("id", "is_active")
@extend_schema_view(list=extend_schema(
summary='Method provides a paginated list of users registered on the server',
parameters=[
OpenApiParameter('id', description='A unique number value identifying this user',
location=OpenApiParameter.QUERY, type=OpenApiTypes.NUMBER),
OpenApiParameter('is_active', description='Returns only active users',
location=OpenApiParameter.QUERY, type=OpenApiTypes.BOOL),
],
responses={
'200': PolymorphicProxySerializer(component_name='MetaUser',
serializers=[
......@@ -1272,12 +1228,15 @@ class UserFilter(filters.FilterSet):
}, tags=['users'], versions=['2.0']))
class UserViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin):
queryset = User.objects.prefetch_related('groups').all().order_by('id')
queryset = User.objects.prefetch_related('groups').all()
http_method_names = ['get', 'post', 'head', 'patch', 'delete']
search_fields = ('username', 'first_name', 'last_name')
filterset_class = UserFilter
iam_organization_field = 'memberships__organization'
filter_fields = ('id', 'is_active', 'username')
ordering_fields = filter_fields
ordering = "-id"
def get_queryset(self):
queryset = super().get_queryset()
if self.action == 'list':
......@@ -1314,29 +1273,6 @@ class UserViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
serializer = serializer_class(request.user, context={ "request": request })
return Response(serializer.data)
# TODO: it will be good to find a way to define description using drf_spectacular.
# But now it will be enough to use an example
# class RedefineDescriptionField(FieldInspector):
# # pylint: disable=no-self-use
# def process_result(self, result, method_name, obj, **kwargs):
# if isinstance(result, openapi.Schema):
# if hasattr(result, 'title') and result.title == 'Specific attributes':
# result.description = 'structure like key1=value1&key2=value2\n' \
# 'supported: range=aws_range'
# return result
class CloudStorageFilter(filters.FilterSet):
display_name = filters.CharFilter(field_name='display_name', lookup_expr='icontains')
provider_type = filters.CharFilter(field_name='provider_type', lookup_expr='icontains')
resource = filters.CharFilter(field_name='resource', lookup_expr='icontains')
credentials_type = filters.CharFilter(field_name='credentials_type', lookup_expr='icontains')
description = filters.CharFilter(field_name='description', lookup_expr='icontains')
owner = filters.CharFilter(field_name='owner__username', lookup_expr='icontains')
class Meta:
model = models.CloudStorage
fields = ('id', 'display_name', 'provider_type', 'resource', 'credentials_type', 'description', 'owner')
@extend_schema_view(retrieve=extend_schema(
summary='Method returns details of a specific cloud storage',
responses={
......@@ -1344,20 +1280,6 @@ class CloudStorageFilter(filters.FilterSet):
}, tags=['cloud storages'], versions=['2.0']))
@extend_schema_view(list=extend_schema(
summary='Returns a paginated list of storages according to query parameters',
parameters=[
OpenApiParameter('provider_type', description='A supported provider of cloud storages',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=CloudProviderChoice.list()),
OpenApiParameter('display_name', description='A display name of storage',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter('resource', description='A name of bucket or container',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter('owner', description='A resource owner',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter('credentials_type', description='A type of a granting access',
location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, enum=CredentialsTypeChoice.list()),
],
#FIXME
#field_inspectors=[RedefineDescriptionField]
responses={
'200': CloudStorageReadSerializer(many=True),
}, tags=['cloud storages'], versions=['2.0']))
......@@ -1368,23 +1290,24 @@ class CloudStorageFilter(filters.FilterSet):
}, tags=['cloud storages'], versions=['2.0']))
@extend_schema_view(partial_update=extend_schema(
summary='Methods does a partial update of chosen fields in a cloud storage instance',
# FIXME
#field_inspectors=[RedefineDescriptionField]
responses={
'200': CloudStorageWriteSerializer,
}, tags=['cloud storages'], versions=['2.0']))
@extend_schema_view(create=extend_schema(
summary='Method creates a cloud storage with a specified characteristics',
# FIXME
#field_inspectors=[RedefineDescriptionField],
responses={
'201': CloudStorageWriteSerializer,
}, tags=['cloud storages'], versions=['2.0']))
class CloudStorageViewSet(viewsets.ModelViewSet):
http_method_names = ['get', 'post', 'patch', 'delete']
queryset = CloudStorageModel.objects.all().prefetch_related('data').order_by('-id')
search_fields = ('provider_type', 'display_name', 'resource', 'credentials_type', 'owner__username', 'description')
filterset_class = CloudStorageFilter
queryset = CloudStorageModel.objects.all().prefetch_related('data')
search_fields = ('provider_type', 'display_name', 'resource',
'credentials_type', 'owner', 'description')
filter_fields = list(search_fields) + ['id']
ordering_fields = filter_fields
ordering = "-id"
lookup_fields = {'owner': 'owner__username'}
iam_organization_field = 'organization'
def get_serializer_class(self):
......
......@@ -5,7 +5,6 @@
from rest_framework import mixins, viewsets
from rest_framework.permissions import SAFE_METHODS
from django.utils.crypto import get_random_string
from django_filters import rest_framework as filters
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
......@@ -18,7 +17,6 @@ from .serializers import (
MembershipReadSerializer, MembershipWriteSerializer,
OrganizationReadSerializer, OrganizationWriteSerializer)
@extend_schema_view(retrieve=extend_schema(
summary='Method returns details of an organization',
responses={
......@@ -51,7 +49,11 @@ from .serializers import (
}, tags=['organizations'], versions=['2.0']))
class OrganizationViewSet(viewsets.ModelViewSet):
queryset = Organization.objects.all()
ordering = ['-id']
search_fields = ('name', 'owner')
filter_fields = list(search_fields) + ['id', 'slug']
lookup_fields = {'owner': 'owner__username'}
ordering_fields = filter_fields
ordering = '-id'
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
pagination_class = None
iam_organization_field = None
......@@ -73,9 +75,6 @@ class OrganizationViewSet(viewsets.ModelViewSet):
extra_kwargs.update({ 'name': serializer.validated_data['slug'] })
serializer.save(**extra_kwargs)
class MembershipFilter(filters.FilterSet):
user = filters.CharFilter(field_name="user__id")
class Meta:
model = Membership
fields = ("user", )
......@@ -107,9 +106,12 @@ class MembershipFilter(filters.FilterSet):
class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin,
mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
queryset = Membership.objects.all()
ordering = ['-id']
ordering = '-id'
http_method_names = ['get', 'patch', 'delete', 'head', 'options']
filterset_class = MembershipFilter
search_fields = ('user_name', 'role')
filter_fields = list(search_fields) + ['id', 'user']
ordering_fields = filter_fields
lookup_fields = {'user': 'user__id', 'user_name': 'user__username'}
iam_organization_field = 'organization'
def get_serializer_class(self):
......@@ -156,10 +158,15 @@ class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin,
}, tags=['invitations'], versions=['2.0']))
class InvitationViewSet(viewsets.ModelViewSet):
queryset = Invitation.objects.all()
ordering = ['-created_date']
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
iam_organization_field = 'membership__organization'
search_fields = ('owner',)
filter_fields = search_fields
ordering_fields = list(filter_fields) + ['created_date']
ordering = '-created_date'
lookup_fields = {'owner': 'owner__username'}
def get_serializer_class(self):
if self.request.method in SAFE_METHODS:
return InvitationReadSerializer
......
......@@ -111,7 +111,6 @@ INSTALLED_APPS = [
'dj_pagination',
'rest_framework',
'rest_framework.authtoken',
'django_filters',
'drf_spectacular',
'rest_auth',
'django.contrib.sites',
......@@ -163,11 +162,12 @@ REST_FRAMEWORK = {
'cvat.apps.engine.pagination.CustomPagination',
'PAGE_SIZE': 10,
'DEFAULT_FILTER_BACKENDS': (
'rest_framework.filters.SearchFilter',
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.OrderingFilter',
'cvat.apps.engine.filters.SearchFilter',
'cvat.apps.engine.filters.OrderingFilter',
'cvat.apps.engine.filters.JsonLogicFilter',
'cvat.apps.iam.filters.OrganizationFilterBackend'),
'SEARCH_PARAM': 'search',
# Disable default handling of the 'format' query parameter by REST framework
'URL_FORMAT_OVERRIDE': 'scheme',
'DEFAULT_THROTTLE_CLASSES': [
......
......@@ -29,6 +29,7 @@ context('Search task feature.', () => {
cy.assignTaskToUser('');
});
// TODO: rework this test
describe(`Testing case "${caseId}"`, () => {
it('Tooltip task filter contain all the possible options.', () => {
cy.get('.cvat-search-field').trigger('mouseover');
......
......@@ -4,29 +4,23 @@
"previous": null,
"results": [
{
"assignee": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"assignee": null,
"bug_tracker": null,
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "2d",
"id": 1,
"id": 9,
"labels": [
{
"attributes": [],
"color": "#6080c0",
"id": 1,
"id": 11,
"name": "cat"
},
{
"attributes": [],
"color": "#406040",
"id": 2,
"id": 12,
"name": "dog"
}
],
......@@ -34,48 +28,104 @@
"project_id": null,
"stage": "annotation",
"start_frame": 0,
"state": "in progress",
"status": "annotation",
"stop_frame": 10,
"task_id": 7,
"url": "http://localhost:8080/api/jobs/9"
},
{
"assignee": null,
"bug_tracker": null,
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "3d",
"id": 8,
"labels": [
{
"attributes": [],
"color": "#2080c0",
"id": 10,
"name": "car"
}
],
"mode": "annotation",
"project_id": null,
"stage": "annotation",
"start_frame": 0,
"state": "new",
"status": "annotation",
"stop_frame": 129,
"task_id": 1,
"url": "http://localhost:8080/api/jobs/1"
"stop_frame": 0,
"task_id": 6,
"url": "http://localhost:8080/api/jobs/8"
},
{
"assignee": {
"first_name": "Worker",
"id": 6,
"last_name": "First",
"url": "http://localhost:8080/api/users/6",
"username": "worker1"
"id": 9,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/9",
"username": "worker4"
},
"bug_tracker": null,
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "2d",
"id": 2,
"id": 7,
"labels": [
{
"attributes": [],
"color": "#2080c0",
"id": 3,
"id": 9,
"name": "car"
}
],
"mode": "interpolation",
"project_id": null,
"stage": "annotation",
"start_frame": 0,
"state": "in progress",
"status": "annotation",
"stop_frame": 24,
"task_id": 5,
"url": "http://localhost:8080/api/jobs/7"
},
{
"assignee": {
"first_name": "Worker",
"id": 7,
"last_name": "Second",
"url": "http://localhost:8080/api/users/7",
"username": "worker2"
},
"bug_tracker": "",
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "2d",
"id": 6,
"labels": [
{
"attributes": [],
"color": "#6080c0",
"id": 7,
"name": "cat"
},
{
"attributes": [],
"color": "#c06060",
"id": 4,
"name": "person"
"color": "#406040",
"id": 8,
"name": "dog"
}
],
"mode": "annotation",
"project_id": null,
"project_id": 2,
"stage": "annotation",
"start_frame": 0,
"state": "new",
"status": "annotation",
"stop_frame": 22,
"task_id": 2,
"url": "http://localhost:8080/api/jobs/2"
"stop_frame": 57,
"task_id": 4,
"url": "http://localhost:8080/api/jobs/6"
},
{
"assignee": null,
......@@ -83,7 +133,7 @@
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "2d",
"id": 3,
"id": 5,
"labels": [
{
"attributes": [
......@@ -113,13 +163,13 @@
],
"mode": "annotation",
"project_id": 1,
"stage": "annotation",
"start_frame": 0,
"state": "in progress",
"status": "annotation",
"stop_frame": 49,
"stage": "acceptance",
"start_frame": 100,
"state": "new",
"status": "validation",
"stop_frame": 147,
"task_id": 3,
"url": "http://localhost:8080/api/jobs/3"
"url": "http://localhost:8080/api/jobs/5"
},
{
"assignee": null,
......@@ -171,7 +221,7 @@
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "2d",
"id": 5,
"id": 3,
"labels": [
{
"attributes": [
......@@ -201,95 +251,39 @@
],
"mode": "annotation",
"project_id": 1,
"stage": "acceptance",
"start_frame": 100,
"state": "new",
"status": "validation",
"stop_frame": 147,
"task_id": 3,
"url": "http://localhost:8080/api/jobs/5"
},
{
"assignee": {
"first_name": "Worker",
"id": 7,
"last_name": "Second",
"url": "http://localhost:8080/api/users/7",
"username": "worker2"
},
"bug_tracker": "",
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "2d",
"id": 6,
"labels": [
{
"attributes": [],
"color": "#6080c0",
"id": 7,
"name": "cat"
},
{
"attributes": [],
"color": "#406040",
"id": 8,
"name": "dog"
}
],
"mode": "annotation",
"project_id": 2,
"stage": "annotation",
"start_frame": 0,
"state": "new",
"state": "in progress",
"status": "annotation",
"stop_frame": 57,
"task_id": 4,
"url": "http://localhost:8080/api/jobs/6"
"stop_frame": 49,
"task_id": 3,
"url": "http://localhost:8080/api/jobs/3"
},
{
"assignee": {
"first_name": "Worker",
"id": 9,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/9",
"username": "worker4"
"id": 6,
"last_name": "First",
"url": "http://localhost:8080/api/users/6",
"username": "worker1"
},
"bug_tracker": null,
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "2d",
"id": 7,
"id": 2,
"labels": [
{
"attributes": [],
"color": "#2080c0",
"id": 9,
"id": 3,
"name": "car"
}
],
"mode": "interpolation",
"project_id": null,
"stage": "annotation",
"start_frame": 0,
"state": "in progress",
"status": "annotation",
"stop_frame": 24,
"task_id": 5,
"url": "http://localhost:8080/api/jobs/7"
},
{
"assignee": null,
"bug_tracker": null,
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "3d",
"id": 8,
"labels": [
},
{
"attributes": [],
"color": "#2080c0",
"id": 10,
"name": "car"
"color": "#c06060",
"id": 4,
"name": "person"
}
],
"mode": "annotation",
......@@ -298,28 +292,34 @@
"start_frame": 0,
"state": "new",
"status": "annotation",
"stop_frame": 0,
"task_id": 6,
"url": "http://localhost:8080/api/jobs/8"
"stop_frame": 22,
"task_id": 2,
"url": "http://localhost:8080/api/jobs/2"
},
{
"assignee": null,
"assignee": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"bug_tracker": null,
"data_chunk_size": 72,
"data_compressed_chunk_type": "imageset",
"dimension": "2d",
"id": 9,
"id": 1,
"labels": [
{
"attributes": [],
"color": "#6080c0",
"id": 11,
"id": 1,
"name": "cat"
},
{
"attributes": [],
"color": "#406040",
"id": 12,
"id": 2,
"name": "dog"
}
],
......@@ -327,11 +327,11 @@
"project_id": null,
"stage": "annotation",
"start_frame": 0,
"state": "in progress",
"state": "new",
"status": "annotation",
"stop_frame": 10,
"task_id": 7,
"url": "http://localhost:8080/api/jobs/9"
"stop_frame": 129,
"task_id": 1,
"url": "http://localhost:8080/api/jobs/1"
}
]
}
\ No newline at end of file
......@@ -4,164 +4,140 @@
"previous": null,
"results": [
{
"date_joined": "2021-12-14T18:04:57Z",
"email": "admin1@cvat.org",
"first_name": "Admin",
"groups": [
"admin"
],
"id": 1,
"is_active": true,
"is_staff": true,
"is_superuser": true,
"last_login": "2022-02-24T21:25:06.462854Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
{
"date_joined": "2021-12-14T18:21:09Z",
"email": "user1@cvat.org",
"date_joined": "2022-02-24T20:45:19Z",
"email": "user6@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"id": 2,
"id": 20,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2022-02-16T06:24:53.910205Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
"last_login": null,
"last_name": "Sixth",
"url": "http://localhost:8080/api/users/20",
"username": "user6"
},
{
"date_joined": "2021-12-14T18:24:12Z",
"email": "user2@cvat.org",
"date_joined": "2022-02-24T20:45:07Z",
"email": "user5@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"id": 3,
"id": 19,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
},
{
"date_joined": "2021-12-14T18:24:39Z",
"email": "user3@cvat.org",
"first_name": "User",
"date_joined": "2021-12-14T18:38:46Z",
"email": "admin2@cvat.org",
"first_name": "Admin",
"groups": [
"user"
"admin"
],
"id": 4,
"id": 18,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"is_staff": true,
"is_superuser": true,
"last_login": null,
"last_name": "Third",
"url": "http://localhost:8080/api/users/4",
"username": "user3"
"last_name": "Second",
"url": "http://localhost:8080/api/users/18",
"username": "admin2"
},
{
"date_joined": "2021-12-14T18:25:10Z",
"email": "user4@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"id": 5,
"date_joined": "2021-12-14T18:37:41Z",
"email": "dummy4@cvat.org",
"first_name": "Dummy",
"groups": [],
"id": 17,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/5",
"username": "user4"
"url": "http://localhost:8080/api/users/17",
"username": "dummy4"
},
{
"date_joined": "2021-12-14T18:30:00Z",
"email": "worker1@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"id": 6,
"date_joined": "2021-12-14T18:37:09Z",
"email": "dummy3@cvat.org",
"first_name": "Dummy",
"groups": [],
"id": 16,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2021-12-14T19:11:21.048740Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/6",
"username": "worker1"
"last_login": null,
"last_name": "Third",
"url": "http://localhost:8080/api/users/16",
"username": "dummy3"
},
{
"date_joined": "2021-12-14T18:30:43Z",
"email": "worker2@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"id": 7,
"date_joined": "2021-12-14T18:36:31Z",
"email": "dummy2@cvat.org",
"first_name": "Dummy",
"groups": [],
"id": 15,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Second",
"url": "http://localhost:8080/api/users/7",
"username": "worker2"
"url": "http://localhost:8080/api/users/15",
"username": "dummy2"
},
{
"date_joined": "2021-12-14T18:31:25Z",
"email": "worker3@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"id": 8,
"date_joined": "2021-12-14T18:36:00Z",
"email": "dummy1@cvat.org",
"first_name": "Dummy",
"groups": [],
"id": 14,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Third",
"url": "http://localhost:8080/api/users/8",
"username": "worker3"
"last_name": "First",
"url": "http://localhost:8080/api/users/14",
"username": "dummy1"
},
{
"date_joined": "2021-12-14T18:32:01Z",
"email": "worker4@cvat.org",
"first_name": "Worker",
"date_joined": "2021-12-14T18:35:15Z",
"email": "business4@cvat.org",
"first_name": "Business",
"groups": [
"worker"
"business"
],
"id": 9,
"id": 13,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/9",
"username": "worker4"
"url": "http://localhost:8080/api/users/13",
"username": "business4"
},
{
"date_joined": "2021-12-14T18:33:06Z",
"email": "business1@cvat.org",
"date_joined": "2021-12-14T18:34:34Z",
"email": "business3@cvat.org",
"first_name": "Business",
"groups": [
"business"
],
"id": 10,
"id": 12,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2022-01-19T13:52:59.477881Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/10",
"username": "business1"
"last_login": null,
"last_name": "Third",
"url": "http://localhost:8080/api/users/12",
"username": "business3"
},
{
"date_joined": "2021-12-14T18:34:01Z",
......@@ -180,140 +156,164 @@
"username": "business2"
},
{
"date_joined": "2021-12-14T18:34:34Z",
"email": "business3@cvat.org",
"date_joined": "2021-12-14T18:33:06Z",
"email": "business1@cvat.org",
"first_name": "Business",
"groups": [
"business"
],
"id": 12,
"id": 10,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Third",
"url": "http://localhost:8080/api/users/12",
"username": "business3"
"last_login": "2022-01-19T13:52:59.477881Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/10",
"username": "business1"
},
{
"date_joined": "2021-12-14T18:35:15Z",
"email": "business4@cvat.org",
"first_name": "Business",
"date_joined": "2021-12-14T18:32:01Z",
"email": "worker4@cvat.org",
"first_name": "Worker",
"groups": [
"business"
"worker"
],
"id": 13,
"id": 9,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/13",
"username": "business4"
"url": "http://localhost:8080/api/users/9",
"username": "worker4"
},
{
"date_joined": "2021-12-14T18:36:00Z",
"email": "dummy1@cvat.org",
"first_name": "Dummy",
"groups": [],
"id": 14,
"date_joined": "2021-12-14T18:31:25Z",
"email": "worker3@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"id": 8,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "First",
"url": "http://localhost:8080/api/users/14",
"username": "dummy1"
"last_name": "Third",
"url": "http://localhost:8080/api/users/8",
"username": "worker3"
},
{
"date_joined": "2021-12-14T18:36:31Z",
"email": "dummy2@cvat.org",
"first_name": "Dummy",
"groups": [],
"id": 15,
"date_joined": "2021-12-14T18:30:43Z",
"email": "worker2@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"id": 7,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Second",
"url": "http://localhost:8080/api/users/15",
"username": "dummy2"
"url": "http://localhost:8080/api/users/7",
"username": "worker2"
},
{
"date_joined": "2021-12-14T18:37:09Z",
"email": "dummy3@cvat.org",
"first_name": "Dummy",
"groups": [],
"id": 16,
"date_joined": "2021-12-14T18:30:00Z",
"email": "worker1@cvat.org",
"first_name": "Worker",
"groups": [
"worker"
],
"id": 6,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Third",
"url": "http://localhost:8080/api/users/16",
"username": "dummy3"
"last_login": "2021-12-14T19:11:21.048740Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/6",
"username": "worker1"
},
{
"date_joined": "2021-12-14T18:37:41Z",
"email": "dummy4@cvat.org",
"first_name": "Dummy",
"groups": [],
"id": 17,
"date_joined": "2021-12-14T18:25:10Z",
"email": "user4@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"id": 5,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/17",
"username": "dummy4"
"url": "http://localhost:8080/api/users/5",
"username": "user4"
},
{
"date_joined": "2021-12-14T18:38:46Z",
"email": "admin2@cvat.org",
"first_name": "Admin",
"date_joined": "2021-12-14T18:24:39Z",
"email": "user3@cvat.org",
"first_name": "User",
"groups": [
"admin"
"user"
],
"id": 18,
"id": 4,
"is_active": true,
"is_staff": true,
"is_superuser": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Second",
"url": "http://localhost:8080/api/users/18",
"username": "admin2"
"last_name": "Third",
"url": "http://localhost:8080/api/users/4",
"username": "user3"
},
{
"date_joined": "2022-02-24T20:45:07Z",
"email": "user5@cvat.org",
"date_joined": "2021-12-14T18:24:12Z",
"email": "user2@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"id": 19,
"id": 3,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
},
{
"date_joined": "2022-02-24T20:45:19Z",
"email": "user6@cvat.org",
"date_joined": "2021-12-14T18:21:09Z",
"email": "user1@cvat.org",
"first_name": "User",
"groups": [
"user"
],
"id": 20,
"id": 2,
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"last_name": "Sixth",
"url": "http://localhost:8080/api/users/20",
"username": "user6"
"last_login": "2022-02-16T06:24:53.910205Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
{
"date_joined": "2021-12-14T18:04:57Z",
"email": "admin1@cvat.org",
"first_name": "Admin",
"groups": [
"admin"
],
"id": 1,
"is_active": true,
"is_staff": true,
"is_superuser": true,
"last_login": "2022-02-24T21:25:06.462854Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
}
]
}
\ No newline at end of file
......@@ -234,4 +234,10 @@ def find_job_staff_user(is_job_staff):
if is_staff == is_job_staff(user['id'], job['id']):
return user['username'], job['id']
return None, None
return find
@pytest.fixture(scope='module')
def filter_jobs_with_shapes(annotations):
def find(jobs):
return list(filter(lambda j: annotations['job'][str(j['id'])]['shapes'], jobs))
return find
\ No newline at end of file
......@@ -24,4 +24,4 @@ def test_check_objects_integrity(path):
resp_objs = response.json()
assert DeepDiff(json_objs, resp_objs, ignore_order=True,
exclude_regex_paths="root\['results'\]\[\d+\]\['last_login'\]") == {}
exclude_regex_paths=r"root\['results'\]\[d+\]\['last_login'\]") == {}
......@@ -110,7 +110,6 @@ class TestListJobs:
else:
self._test_list_jobs_403(user['username'], **kwargs)
class TestGetAnnotations:
def _test_get_job_annotations_200(self, user, jid, data, **kwargs):
response = get_method(user, f'jobs/{jid}/annotations', **kwargs)
......@@ -177,7 +176,6 @@ class TestGetAnnotations:
else:
self._test_get_job_annotations_403(username, job_id, **kwargs)
class TestPatchJobAnnotations:
_ORG = 2
......@@ -205,10 +203,11 @@ class TestPatchJobAnnotations:
('supervisor', True, True), ('worker', True, True)
])
def test_member_update_job_annotations(self, org, role, job_staff, is_allow,
find_job_staff_user, find_users, request_data, jobs_by_org):
find_job_staff_user, find_users, request_data, jobs_by_org, filter_jobs_with_shapes):
users = find_users(role=role, org=org)
jobs = jobs_by_org[org]
username, jid = find_job_staff_user(jobs, users, job_staff)
filtered_jobs = filter_jobs_with_shapes(jobs)
username, jid = find_job_staff_user(filtered_jobs, users, job_staff)
data = request_data(jid)
response = patch_method(username, f'jobs/{jid}/annotations',
......@@ -222,10 +221,11 @@ class TestPatchJobAnnotations:
('admin', True), ('business', False), ('worker', False), ('user', False)
])
def test_non_member_update_job_annotations(self, org, privilege, is_allow,
find_job_staff_user, find_users, request_data, jobs_by_org):
find_job_staff_user, find_users, request_data, jobs_by_org, filter_jobs_with_shapes):
users = find_users(privilege=privilege, exclude_org=org)
jobs = jobs_by_org[org]
username, jid = find_job_staff_user(jobs, users, False)
filtered_jobs = filter_jobs_with_shapes(jobs)
username, jid = find_job_staff_user(filtered_jobs, users, False)
data = request_data(jid)
response = patch_method(username, f'jobs/{jid}/annotations', data,
......@@ -241,10 +241,11 @@ class TestPatchJobAnnotations:
('user', True, True), ('user', False, False)
])
def test_user_update_job_annotations(self, org, privilege, job_staff, is_allow,
find_job_staff_user, find_users, request_data, jobs_by_org):
find_job_staff_user, find_users, request_data, jobs_by_org, filter_jobs_with_shapes):
users = find_users(privilege=privilege)
jobs = jobs_by_org[org]
username, jid = find_job_staff_user(jobs, users, job_staff)
filtered_jobs = filter_jobs_with_shapes(jobs)
username, jid = find_job_staff_user(filtered_jobs, users, job_staff)
data = request_data(jid)
response = patch_method(username, f'jobs/{jid}/annotations', data,
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册