diff --git a/CHANGELOG.md b/CHANGELOG.md index 390fd8ca65e..61f26f2ddde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 () - Added OpenCV.js TrackerMIL as tracking tool () - Ability to continue working from the latest frame where an annotator was before () +- Advanced filtration and sorting for a list of jobs () + ### Changed diff --git a/cvat-core/package-lock.json b/cvat-core/package-lock.json index 9b53fa64b62..dacb7f466f8 100644 --- a/cvat-core/package-lock.json +++ b/cvat-core/package-lock.json @@ -1,12 +1,12 @@ { "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", diff --git a/cvat-core/package.json b/cvat-core/package.json index 106426cb2cf..098ddd8a2e1 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "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": { diff --git a/cvat-core/src/api-implementation.js b/cvat-core/src/api-implementation.js index e0245df766f..98bf84de335 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -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; diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index 9ad28db2265..722ce925c7f 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -1,4 +1,4 @@ -// 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 diff --git a/cvat-core/src/cloud-storage.js b/cvat-core/src/cloud-storage.js index b4799962cc7..9be108b3ffa 100644 --- a/cvat-core/src/cloud-storage.js +++ b/cvat-core/src/cloud-storage.js @@ -1,4 +1,4 @@ -// 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, }; diff --git a/cvat-core/src/common.js b/cvat-core/src/common.js index 0710e99f142..bb47060c81c 100644 --- a/cvat-core/src/common.js +++ b/cvat-core/src/common.js @@ -1,4 +1,4 @@ -// 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`); } } } diff --git a/cvat-core/src/organization.js b/cvat-core/src/organization.js index f3e13190544..e116415f5fd 100644 --- a/cvat-core/src/organization.js +++ b/cvat-core/src/organization.js @@ -1,4 +1,4 @@ -// 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}`); diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index fd5809d6cab..5803fcc20b1 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -1891,7 +1891,7 @@ return ''; } - const frameData = await getPreview(this.taskId, this.jobID); + const frameData = await getPreview(this.taskId, this.id); return frameData; }; diff --git a/cvat-core/tests/api/cloud-storages.js b/cvat-core/tests/api/cloud-storages.js index 0af69d62f1d..66ccf11abdc 100644 --- a/cvat-core/tests/api/cloud-storages.js +++ b/cvat-core/tests/api/cloud-storages.js @@ -1,4 +1,4 @@ -// 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 () => { diff --git a/cvat-core/tests/api/projects.js b/cvat-core/tests/api/projects.js index af6390dc282..ea278c24146 100644 --- a/cvat-core/tests/api/projects.js +++ b/cvat-core/tests/api/projects.js @@ -1,4 +1,4 @@ -// 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', diff --git a/cvat-core/tests/api/tasks.js b/cvat-core/tests/api/tasks.js index 94ac1116a47..ea1299dda4c 100644 --- a/cvat-core/tests/api/tasks.js +++ b/cvat-core/tests/api/tasks.js @@ -1,4 +1,4 @@ -// 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', () => { diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index 895eedef66b..0da910a7651 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,12 +1,12 @@ { "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", diff --git a/cvat-ui/package.json b/cvat-ui/package.json index e01199e4b3f..c5ffeab7d5f 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "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", diff --git a/cvat-ui/src/actions/cloud-storage-actions.ts b/cvat-ui/src/actions/cloud-storage-actions.ts index c2aad8f322a..f7cb770368e 100644 --- a/cvat-ui/src/actions/cloud-storage-actions.ts +++ b/cvat-ui/src/actions/cloud-storage-actions.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2021 Intel Corporation +// Copyright (C) 2021-2022 Intel Corporation // // SPDX-License-Identifier: MIT @@ -103,6 +103,13 @@ export type CloudStorageActions = ActionUnion; export function getCloudStoragesAsync(query: Partial): ThunkAction { return async (dispatch: ActionCreator): Promise => { + 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): 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((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); diff --git a/cvat-ui/src/actions/jobs-actions.ts b/cvat-ui/src/actions/jobs-actions.ts index 2482fb5ed9d..d49f8c3fc29 100644 --- a/cvat-ui/src/actions/jobs-actions.ts +++ b/cvat-ui/src/actions/jobs-actions.ts @@ -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 = { ...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); diff --git a/cvat-ui/src/actions/projects-actions.ts b/cvat-ui/src/actions/projects-actions.ts index 61b9105bfa2..eafc7beef85 100644 --- a/cvat-ui/src/actions/projects-actions.ts +++ b/cvat-ui/src/actions/projects-actions.ts @@ -1,4 +1,4 @@ -// 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((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); diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 53e91d51283..0f29cf7a5d5 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -1,4 +1,4 @@ -// 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, {}, } } + // 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((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); diff --git a/cvat-ui/src/components/create-cloud-storage-page/cloud-storage-form.tsx b/cvat-ui/src/components/create-cloud-storage-page/cloud-storage-form.tsx index 6913130cc3a..bd34dd95adf 100644 --- a/cvat-ui/src/components/create-cloud-storage-page/cloud-storage-form.tsx +++ b/cvat-ui/src/components/create-cloud-storage-page/cloud-storage-form.tsx @@ -1,4 +1,4 @@ -// 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, }; diff --git a/cvat-ui/src/components/header/header.tsx b/cvat-ui/src/components/header/header.tsx index 3b22fa972da..395d5db5634 100644 --- a/cvat-ui/src/components/header/header.tsx +++ b/cvat-ui/src/components/header/header.tsx @@ -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 { ); + 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 (
- {isModelsPluginActive && ( + {isModelsPluginActive ? ( - )} - {isAnalyticsPluginActive && ( + ) : null} + {isAnalyticsPluginActive ? ( - )} + ) : null}
diff --git a/cvat-ui/src/components/header/styles.scss b/cvat-ui/src/components/header/styles.scss index d77e09ae096..a0f1efba562 100644 --- a/cvat-ui/src/components/header/styles.scss +++ b/cvat-ui/src/components/header/styles.scss @@ -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; diff --git a/cvat-ui/src/components/jobs-page/filtering.tsx b/cvat-ui/src/components/jobs-page/filtering.tsx new file mode 100644 index 00000000000..2a494866c4a --- /dev/null +++ b/cvat-ui/src/components/jobs-page/filtering.tsx @@ -0,0 +1,335 @@ +// 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, + localStorageRecentKeyword: string, + localStorageRecentCapacity: number, + predefinedFilterValues: Record, + defaultEnabledFilters: string[], +): React.FunctionComponent { + 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 { + 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, 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(false); + const [recentFilters, setRecentFilters] = useState>({}); + const [predefinedFilters, setPredefinedFilters] = useState>({}); + const [appliedFilter, setAppliedFilter] = useState(defaultAppliedFilter); + const [state, setState] = useState(defaultTree); + + useEffect(() => { + setRecentFilters(receiveRecentFilters()); + setIsMounted(true); + }, []); + + useEffect(() => { + if (user) { + const result: Record = {}; + for (const key of Object.keys(predefinedFilterValues)) { + result[key] = predefinedFilterValues[key].replace('', `${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 => ( +
+
+ +
+
+ ); + + return ( +
+ + {Object.keys(predefinedFilters).map((key: string): JSX.Element => ( + { + 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} + + )) } +
+ )} + > + + + + { Object.keys(recentFilters).length ? ( + + + {Object.keys(recentFilters).map((key: string): JSX.Element | null => { + const tree = QbUtils.loadFromJsonLogic(JSON.parse(key), config); + + if (!tree) { + return null; + } + + return ( + { + if (appliedFilter.recent === key) { + setAppliedFilter(defaultAppliedFilter); + } else { + setAppliedFilter({ + ...defaultAppliedFilter, + recent: key, + }); + } + }} + > + {QbUtils.queryString(tree, config)} + + ); + })} + +
+ )} + > + + + ) : null} + + { + setState(tree); + }} + value={state} + renderBuilder={renderBuilder} + /> + + + + + + )} + > + + + + + ); + } + + return React.memo(ResourceFilterComponent); +} diff --git a/cvat-ui/src/components/jobs-page/job-card.tsx b/cvat-ui/src/components/jobs-page/job-card.tsx index 06e8a960e2c..836e629b94d 100644 --- a/cvat-ui/src/components/jobs-page/job-card.tsx +++ b/cvat-ui/src/components/jobs-page/job-card.tsx @@ -32,8 +32,14 @@ function JobCardComponent(props: Props): JSX.Element { const [expanded, setExpanded] = useState(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 ( diff --git a/cvat-ui/src/components/jobs-page/jobs-filter-configuration.ts b/cvat-ui/src/components/jobs-page/jobs-filter-configuration.ts new file mode 100644 index 00000000000..95c277303c3 --- /dev/null +++ b/cvat-ui/src/components/jobs-page/jobs-filter-configuration.ts @@ -0,0 +1,121 @@ +// Copyright (C) 2022 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import { Config } from 'react-awesome-query-builder'; + +export const config: Partial = { + 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"},""]}]}', + 'Not completed': '{"!":{"or":[{"==":[{"var":"state"},"completed"]},{"==":[{"var":"stage"},"acceptance"]}]}}', +}; +export const defaultEnabledFilters = ['Not completed']; diff --git a/cvat-ui/src/components/jobs-page/jobs-page.tsx b/cvat-ui/src/components/jobs-page/jobs-page.tsx index 0bd76b12803..f6d8846e6ad 100644 --- a/cvat-ui/src/components/jobs-page/jobs-page.tsx +++ b/cvat-ui/src/components/jobs-page/jobs-page.tsx @@ -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 ( -
- -
- ); - } const dimensions = { md: 22, @@ -71,43 +29,65 @@ function JobsPageComponent(): JSX.Element { xxl: 16, }; + const content = count ? ( + <> + + + + { + dispatch(getJobsAsync({ + ...query, + page, + })); + }} + showSizeChanger={false} + total={count} + pageSize={12} + current={query.page} + showQuickJumper + /> + + + + ) : ; + return (
) => { + 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 ? ( - <> - - - - { - dispatch(getJobsAsync({ - ...query, - page, - })); - }} - showSizeChanger={false} - total={count} - pageSize={12} - current={query.page} - showQuickJumper - /> - - - - ) : } + { fetching ? ( + + ) : content }
); diff --git a/cvat-ui/src/components/jobs-page/sorting.tsx b/cvat-ui/src/components/jobs-page/sorting.tsx new file mode 100644 index 00000000000..1215c3bf2d9 --- /dev/null +++ b/cvat-ui/src/components/jobs-page/sorting.tsx @@ -0,0 +1,182 @@ +// 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; + setAppliedSorting: (arg: Record) => 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 ( +
+ ); + } + + return ( +
+ anchorIndex}>{value} +
+ + + +
+
+ ); + }, +); + +const SortableList = SortableContainer( + ({ items, appliedSorting, setAppliedSorting } : + { + items: string[]; + appliedSorting: Record; + setAppliedSorting: (arg: Record) => void; + }) => ( +
+ { items.map((value: string, index: number) => ( + + )) } +
+ ), +); + +function SortingModalComponent(props: Props): JSX.Element { + const { + sortingFields: sortingFieldsProp, + defaultFields, visible, onApplySorting, onVisibleChange, + } = props; + const [appliedSorting, setAppliedSorting] = useState>( + defaultFields.reduce((acc: Record, 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( + Array.from(new Set([...Object.keys(appliedSorting), ANCHOR_KEYWORD, ...sortingFieldsProp])), + ); + const [appliedOrder, setAppliedOrder] = useState([...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 ( + { + 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} + /> + )} + > + + + ); +} + +export default React.memo(SortingModalComponent); diff --git a/cvat-ui/src/components/jobs-page/styles.scss b/cvat-ui/src/components/jobs-page/styles.scss index 572c990f41f..298ae8e7e35 100644 --- a/cvat-ui/src/components/jobs-page/styles.scss +++ b/cvat-ui/src/components/jobs-page/styles.scss @@ -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; + } + } + } } diff --git a/cvat-ui/src/components/jobs-page/top-bar.tsx b/cvat-ui/src/components/jobs-page/top-bar.tsx index e27d02b761e..77c41f6625b 100644 --- a/cvat-ui/src/components/jobs-page/top-bar.tsx +++ b/cvat-ui/src/components/jobs-page/top-bar.tsx @@ -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): 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: ( -
- { - if (value) { - if (query.assignee !== value.username) { - onChangeFilters({ assignee: value.username }); - } - } else if (query.assignee !== null) { - onChangeFilters({ assignee: null }); - } - }} - /> - -
- ), - }, - ]; + const { + query, onApplyFilter, onApplySorting, onApplySearch, + } = props; + const [visibility, setVisibility] = useState(defaultVisibility); return ( - - - Jobs - - ) => { - const processed = Object.fromEntries( - Object.entries(filters) - .map(([key, values]) => ( - [key, typeof values === 'string' || values === null ? values : values.join(',')] - )), - ); - onChangeFilters(processed); +
+ { + onApplySearch(phrase); }} - className='cvat-jobs-page-filters' - columns={columns} - size='small' + defaultValue={query.search || ''} + className='cvat-jobs-page-search-bar' + placeholder='Search ..' /> - +
+ ( + 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} + /> + ( + setVisibility({ ...defaultVisibility, predefined: visible }) + )} + onBuilderVisibleChange={(visible: boolean) => ( + setVisibility({ ...defaultVisibility, builder: visible }) + )} + onRecentVisibleChange={(visible: boolean) => ( + setVisibility({ ...defaultVisibility, builder: visibility.builder, recent: visible }) + )} + onApplyFilter={onApplyFilter} + /> +
+
); diff --git a/cvat-ui/src/components/search-tooltip/search-tooltip.tsx b/cvat-ui/src/components/search-tooltip/search-tooltip.tsx index 3fc5dcb7b99..a1e4b866a33 100644 --- a/cvat-ui/src/components/search-tooltip/search-tooltip.tsx +++ b/cvat-ui/src/components/search-tooltip/search-tooltip.tsx @@ -1,4 +1,4 @@ -// 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' ? ( - resourceName: mycvatbucket + resource: mycvatbucket all {instances} diff --git a/cvat-ui/src/reducers/cloud-storages-reducer.ts b/cvat-ui/src/reducers/cloud-storages-reducer.ts index 5e5ecd6f881..92b17a3cea2 100644 --- a/cvat-ui/src/reducers/cloud-storages-reducer.ts +++ b/cvat-ui/src/reducers/cloud-storages-reducer.ts @@ -1,4 +1,4 @@ -// 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, diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 09127cacb7c..5d03f6f975e 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -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; diff --git a/cvat-ui/src/reducers/jobs-reducer.ts b/cvat-ui/src/reducers/jobs-reducer.ts index fca4992a8f9..6547cfe192e 100644 --- a/cvat-ui/src/reducers/jobs-reducer.ts +++ b/cvat-ui/src/reducers/jobs-reducer.ts @@ -10,9 +10,8 @@ const defaultState: JobsState = { count: 0, query: { page: 1, - state: null, - stage: null, - assignee: null, + filter: null, + sort: null, }, current: [], previews: [], diff --git a/cvat/apps/engine/filters.py b/cvat/apps/engine/filters.py new file mode 100644 index 00000000000..051c1c58b4b --- /dev/null +++ b/cvat/apps/engine/filters.py @@ -0,0 +1,218 @@ +# 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 diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index dd845738d1a..6f949695a0c 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -22,7 +22,6 @@ 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.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 @@ def plugins(request): } 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 @@ def __call__(self, request, start, stop, db_data): 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 @@ def dataset_export(self, request, pk): 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 Meta: }, 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 @@ def annotations(self, request, pk): 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 @@ def get_serializer_class(self): 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 Meta: }, 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 @@ def self(self, request): 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 Meta: }, 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 Meta: }, 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): diff --git a/cvat/apps/organizations/views.py b/cvat/apps/organizations/views.py index d6def621cb4..5884961fd93 100644 --- a/cvat/apps/organizations/views.py +++ b/cvat/apps/organizations/views.py @@ -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 @@ MembershipReadSerializer, MembershipWriteSerializer, OrganizationReadSerializer, OrganizationWriteSerializer) - @extend_schema_view(retrieve=extend_schema( summary='Method returns details of an organization', responses={ @@ -51,7 +49,11 @@ }, 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 @@ def perform_create(self, serializer): 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 Meta: 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 @@ def get_queryset(self): }, 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 diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 53f40f66372..59b1e58ab6f 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -111,7 +111,6 @@ def add_ssh_keys(): 'dj_pagination', 'rest_framework', 'rest_framework.authtoken', - 'django_filters', 'drf_spectacular', 'rest_auth', 'django.contrib.sites', @@ -163,11 +162,12 @@ def add_ssh_keys(): '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': [ diff --git a/tests/cypress/integration/actions_tasks2/case_35_search_task_feature.js b/tests/cypress/integration/actions_tasks2/case_35_search_task_feature.js index 0129681544d..ea63b90bbf1 100644 --- a/tests/cypress/integration/actions_tasks2/case_35_search_task_feature.js +++ b/tests/cypress/integration/actions_tasks2/case_35_search_task_feature.js @@ -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'); diff --git a/tests/rest_api/assets/jobs.json b/tests/rest_api/assets/jobs.json index d742c3c4756..94fee66452d 100644 --- a/tests/rest_api/assets/jobs.json +++ b/tests/rest_api/assets/jobs.json @@ -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 diff --git a/tests/rest_api/assets/users.json b/tests/rest_api/assets/users.json index c8775a00df0..db59d869225 100644 --- a/tests/rest_api/assets/users.json +++ b/tests/rest_api/assets/users.json @@ -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 diff --git a/tests/rest_api/conftest.py b/tests/rest_api/conftest.py index 30b08e7033a..b931f3ac6c2 100644 --- a/tests/rest_api/conftest.py +++ b/tests/rest_api/conftest.py @@ -234,4 +234,10 @@ def find(jobs, users, is_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 diff --git a/tests/rest_api/test_check_objects_integrity.py b/tests/rest_api/test_check_objects_integrity.py index d75a9092677..508c625921a 100644 --- a/tests/rest_api/test_check_objects_integrity.py +++ b/tests/rest_api/test_check_objects_integrity.py @@ -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'\]") == {} diff --git a/tests/rest_api/test_jobs.py b/tests/rest_api/test_jobs.py index e17775afec6..3e88990c5d8 100644 --- a/tests/rest_api/test_jobs.py +++ b/tests/rest_api/test_jobs.py @@ -110,7 +110,6 @@ def test_non_admin_list_jobs(self, org_id, groups, users, jobs, tasks, 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 @@ def test_non_member_get_job_annotations(self, org, privilege, is_allow, else: self._test_get_job_annotations_403(username, job_id, **kwargs) - class TestPatchJobAnnotations: _ORG = 2 @@ -205,10 +203,11 @@ def get_data(jid): ('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 @@ def test_member_update_job_annotations(self, org, role, job_staff, is_allow, ('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 @@ def test_non_member_update_job_annotations(self, org, privilege, is_allow, ('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,