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

Co-authored-by: Nikita Manovich <nikita.manovich@intel.com>
Co-authored-by: Maya <maya17grd@gmail.com>
main
Boris Sekachev 4 years ago committed by GitHub
parent c07a93d1ad
commit b5bac8c0a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Basic page with jobs list, basic filtration to this list (<https://github.com/openvinotoolkit/cvat/pull/4258>) - Basic page with jobs list, basic filtration to this list (<https://github.com/openvinotoolkit/cvat/pull/4258>)
- Added OpenCV.js TrackerMIL as tracking tool (<https://github.com/openvinotoolkit/cvat/pull/4200>) - Added OpenCV.js TrackerMIL as tracking tool (<https://github.com/openvinotoolkit/cvat/pull/4200>)
- Ability to continue working from the latest frame where an annotator was before (<https://github.com/openvinotoolkit/cvat/pull/4297>) - Ability to continue working from the latest frame where an annotator was before (<https://github.com/openvinotoolkit/cvat/pull/4297>)
- Advanced filtration and sorting for a list of jobs (<https://github.com/openvinotoolkit/cvat/pull/4319>)
### Changed ### Changed

@ -1,12 +1,12 @@
{ {
"name": "cvat-core", "name": "cvat-core",
"version": "4.2.0", "version": "4.2.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cvat-core", "name": "cvat-core",
"version": "4.2.0", "version": "4.2.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^0.21.4", "axios": "^0.21.4",

@ -1,6 +1,6 @@
{ {
"name": "cvat-core", "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", "description": "Part of Computer Vision Tool which presents an interface for client-side integration",
"main": "babel.config.js", "main": "babel.config.js",
"scripts": { "scripts": {

@ -11,7 +11,6 @@ const config = require('./config');
const { const {
isBoolean, isBoolean,
isInteger, isInteger,
isEnum,
isString, isString,
checkFilter, checkFilter,
checkExclusiveFields, checkExclusiveFields,
@ -19,14 +18,6 @@ const config = require('./config');
checkObjectType, checkObjectType,
} = require('./common'); } = require('./common');
const {
TaskStatus,
TaskMode,
DimensionType,
CloudStorageProviderType,
CloudStorageCredentialsType,
} = require('./enums');
const User = require('./user'); const User = require('./user');
const { AnnotationFormats } = require('./annotation-formats'); const { AnnotationFormats } = require('./annotation-formats');
const { ArgumentError } = require('./exceptions'); const { ArgumentError } = require('./exceptions');
@ -153,9 +144,9 @@ const config = require('./config');
cvat.jobs.get.implementation = async (filter) => { cvat.jobs.get.implementation = async (filter) => {
checkFilter(filter, { checkFilter(filter, {
page: isInteger, page: isInteger,
stage: isString, filter: isString,
state: isString, sort: isString,
assignee: isString, search: isString,
taskID: isInteger, taskID: isInteger,
jobID: isInteger, jobID: isInteger,
}); });
@ -190,32 +181,22 @@ const config = require('./config');
checkFilter(filter, { checkFilter(filter, {
page: isInteger, page: isInteger,
projectId: isInteger, projectId: isInteger,
name: isString,
id: isInteger, id: isInteger,
owner: isString,
assignee: isString,
search: isString, search: isString,
filter: isString,
ordering: 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 = {}; const searchParams = {};
for (const field of [ for (const field of [
'name', 'filter',
'owner',
'assignee',
'search', 'search',
'ordering', 'ordering',
'status',
'mode',
'id', 'id',
'page', 'page',
'projectId', 'projectId',
'dimension',
]) { ]) {
if (Object.prototype.hasOwnProperty.call(filter, field)) { if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams[camelToSnake(field)] = filter[field]; searchParams[camelToSnake(field)] = filter[field];
@ -234,17 +215,14 @@ const config = require('./config');
checkFilter(filter, { checkFilter(filter, {
id: isInteger, id: isInteger,
page: isInteger, page: isInteger,
name: isString,
assignee: isString,
owner: isString,
search: isString, search: isString,
status: isEnum.bind(TaskStatus), filter: isString,
}); });
checkExclusiveFields(filter, ['id', 'search'], ['page']); checkExclusiveFields(filter, ['id'], ['page']);
const searchParams = {}; 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)) { if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams[camelToSnake(field)] = filter[field]; searchParams[camelToSnake(field)] = filter[field];
} }
@ -267,38 +245,25 @@ const config = require('./config');
cvat.cloudStorages.get.implementation = async (filter) => { cvat.cloudStorages.get.implementation = async (filter) => {
checkFilter(filter, { checkFilter(filter, {
page: isInteger, page: isInteger,
displayName: isString, filter: isString,
resourceName: isString,
description: isString,
id: isInteger, id: isInteger,
owner: isString,
search: isString, search: isString,
providerType: isEnum.bind(CloudStorageProviderType),
credentialsType: isEnum.bind(CloudStorageCredentialsType),
}); });
checkExclusiveFields(filter, ['id', 'search'], ['page']); checkExclusiveFields(filter, ['id', 'search'], ['page']);
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
for (const field of [ for (const field of [
'displayName', 'filter',
'credentialsType',
'providerType',
'owner',
'search', 'search',
'id', 'id',
'page', 'page',
'description',
]) { ]) {
if (Object.prototype.hasOwnProperty.call(filter, field)) { if (Object.prototype.hasOwnProperty.call(filter, field)) {
searchParams.set(camelToSnake(field), 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 cloudStoragesData = await serverProxy.cloudStorages.get(searchParams.toString());
const cloudStorages = cloudStoragesData.map((cloudStorage) => new CloudStorage(cloudStorage)); const cloudStorages = cloudStoragesData.map((cloudStorage) => new CloudStorage(cloudStorage));
cloudStorages.count = cloudStoragesData.count; cloudStorages.count = cloudStoragesData.count;

@ -1,4 +1,4 @@
// Copyright (C) 2019-2021 Intel Corporation // Copyright (C) 2019-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -773,7 +773,7 @@ function build() {
/** /**
* @typedef {Object} CloudStorageFilter * @typedef {Object} CloudStorageFilter
* @property {string} displayName Check if displayName contains this value * @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 {module:API.cvat.enums.ProviderType} providerType Check if providerType equal this value
* @property {integer} id Check if id equals this value * @property {integer} id Check if id equals this value
* @property {integer} page Get specific page * @property {integer} page Get specific page

@ -1,4 +1,4 @@
// Copyright (C) 2021 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -174,13 +174,13 @@
}, },
/** /**
* Unique resource name * Unique resource name
* @name resourceName * @name resource
* @type {string} * @type {string}
* @memberof module:API.cvat.classes.CloudStorage * @memberof module:API.cvat.classes.CloudStorage
* @instance * @instance
* @throws {module:API.cvat.exceptions.ArgumentError} * @throws {module:API.cvat.exceptions.ArgumentError}
*/ */
resourceName: { resource: {
get: () => data.resource, get: () => data.resource,
set: (value) => { set: (value) => {
validateNotEmptyString(value); validateNotEmptyString(value);
@ -456,7 +456,7 @@
display_name: this.displayName, display_name: this.displayName,
credentials_type: this.credentialsType, credentials_type: this.credentialsType,
provider_type: this.providerType, provider_type: this.providerType,
resource: this.resourceName, resource: this.resource,
manifests: this.manifests, manifests: this.manifests,
}; };

@ -1,4 +1,4 @@
// Copyright (C) 2019-2021 Intel Corporation // Copyright (C) 2019-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -36,7 +36,7 @@
if (!(prop in fields)) { if (!(prop in fields)) {
throw new ArgumentError(`Unsupported filter property has been received: "${prop}"`); throw new ArgumentError(`Unsupported filter property has been received: "${prop}"`);
} else if (!fields[prop](filter[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`);
} }
} }
} }

@ -1,4 +1,4 @@
// Copyright (C) 2021 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -360,7 +360,13 @@ Organization.prototype.deleteMembership.implementation = async function (members
Organization.prototype.leave.implementation = async function (user) { Organization.prototype.leave.implementation = async function (user) {
checkObjectType('user', user, null, User); checkObjectType('user', user, null, User);
if (typeof this.id === 'number') { 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; const [membership] = result.results;
if (!membership) { if (!membership) {
throw new ServerError(`Could not find membership for user ${user.username} in organization ${this.slug}`); throw new ServerError(`Could not find membership for user ${user.username} in organization ${this.slug}`);

@ -1891,7 +1891,7 @@
return ''; return '';
} }
const frameData = await getPreview(this.taskId, this.jobID); const frameData = await getPreview(this.taskId, this.id);
return frameData; return frameData;
}; };

@ -1,4 +1,4 @@
// Copyright (C) 2021 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -36,7 +36,7 @@ describe('Feature: get cloud storages', () => {
expect(cloudStorage.id).toBe(1); expect(cloudStorage.id).toBe(1);
expect(cloudStorage.providerType).toBe('AWS_S3_BUCKET'); expect(cloudStorage.providerType).toBe('AWS_S3_BUCKET');
expect(cloudStorage.credentialsType).toBe('KEY_SECRET_KEY_PAIR'); 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.displayName).toBe('Demonstration bucket');
expect(cloudStorage.manifests).toHaveLength(1); expect(cloudStorage.manifests).toHaveLength(1);
expect(cloudStorage.manifests[0]).toBe('manifest.jsonl'); expect(cloudStorage.manifests[0]).toBe('manifest.jsonl');
@ -61,41 +61,18 @@ describe('Feature: get cloud storages', () => {
}); });
test('get cloud storages by filters', async () => { test('get cloud storages by filters', async () => {
const filters = [ const filter = {
new Map([ and: [
['providerType', 'AWS_S3_BUCKET'], { '==': [{ var: 'display_name' }, 'Demonstration bucket'] },
['resourceName', 'bucket'], { '==': [{ var: 'resource_name' }, 'bucket'] },
['displayName', 'Demonstration bucket'], { '==': [{ var: 'description' }, 'It is first bucket'] },
['credentialsType', 'KEY_SECRET_KEY_PAIR'], { '==': [{ var: 'provider_type' }, 'AWS_S3_BUCKET'] },
['description', 'It is first bucket'], { '==': [{ var: 'credentials_type' }, 'KEY_SECRET_KEY_PAIR'] },
]), ],
new Map([ };
['providerType', 'AZURE_CONTAINER'],
['resourceName', 'container'], const result = await window.cvat.cloudStorages.get({ filter: JSON.stringify(filter) });
['displayName', 'Demonstration container'], expect(result).toBeInstanceOf(Array);
['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);
});
}));
}); });
test('get cloud storage by invalid filters', async () => { test('get cloud storage by invalid filters', async () => {

@ -1,4 +1,4 @@
// Copyright (C) 2019-2021 Intel Corporation // Copyright (C) 2019-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -54,16 +54,12 @@ describe('Feature: get projects', () => {
test('get projects by filters', async () => { test('get projects by filters', async () => {
const result = await window.cvat.projects.get({ const result = await window.cvat.projects.get({
status: 'completed', filter: '{"and":[{"==":[{"var":"status"},"completed"]}]}',
}); });
expect(Array.isArray(result)).toBeTruthy(); expect(result).toBeInstanceOf(Array);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Project);
expect(result[0].id).toBe(2);
expect(result[0].status).toBe('completed');
}); });
test('get projects by invalid filters', async () => { test('get projects by invalid query', async () => {
expect( expect(
window.cvat.projects.get({ window.cvat.projects.get({
unknown: '5', unknown: '5',

@ -1,4 +1,4 @@
// Copyright (C) 2020-2021 Intel Corporation // Copyright (C) 2020-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -52,39 +52,18 @@ describe('Feature: get a list of tasks', () => {
test('get tasks by filters', async () => { test('get tasks by filters', async () => {
const result = await window.cvat.tasks.get({ const result = await window.cvat.tasks.get({
mode: 'interpolation', filter: '{"and":[{"==":[{"var":"filter"},"interpolation"]}]}',
}); });
expect(Array.isArray(result)).toBeTruthy(); expect(result).toBeInstanceOf(Array);
expect(result).toHaveLength(3);
for (const el of result) {
expect(el).toBeInstanceOf(Task);
expect(el.mode).toBe('interpolation');
}
}); });
test('get tasks by invalid filters', async () => { test('get tasks by invalid query', async () => {
expect( expect(
window.cvat.tasks.get({ window.cvat.tasks.get({
unknown: '5', unknown: '5',
}), }),
).rejects.toThrow(window.cvat.exceptions.ArgumentError); ).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', () => { describe('Feature: save a task', () => {

@ -1,12 +1,12 @@
{ {
"name": "cvat-ui", "name": "cvat-ui",
"version": "1.35.2", "version": "1.36.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cvat-ui", "name": "cvat-ui",
"version": "1.35.2", "version": "1.36.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ant-design/icons": "^4.6.3", "@ant-design/icons": "^4.6.3",
@ -45,6 +45,7 @@
"react-router": "^5.1.0", "react-router": "^5.1.0",
"react-router-dom": "^5.1.0", "react-router-dom": "^5.1.0",
"react-share": "^4.4.0", "react-share": "^4.4.0",
"react-sortable-hoc": "^2.0.0",
"redux": "^4.1.1", "redux": "^4.1.1",
"redux-devtools-extension": "^2.13.9", "redux-devtools-extension": "^2.13.9",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
@ -53,16 +54,17 @@
"devDependencies": {} "devDependencies": {}
}, },
"../cvat-canvas": { "../cvat-canvas": {
"version": "2.8.0", "version": "2.13.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/polylabel": "^1.0.5",
"polylabel": "^1.1.0",
"svg.draggable.js": "2.2.2", "svg.draggable.js": "2.2.2",
"svg.draw.js": "^2.0.4", "svg.draw.js": "^2.0.4",
"svg.js": "2.7.1", "svg.js": "2.7.1",
"svg.resize.js": "1.4.3", "svg.resize.js": "1.4.3",
"svg.select.js": "3.0.1" "svg.select.js": "3.0.1"
}, }
"devDependencies": {}
}, },
"../cvat-canvas3d": { "../cvat-canvas3d": {
"version": "0.0.1", "version": "0.0.1",
@ -75,13 +77,13 @@
"devDependencies": {} "devDependencies": {}
}, },
"../cvat-core": { "../cvat-core": {
"version": "3.16.1", "version": "4.2.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^0.21.4", "axios": "^0.21.4",
"browser-or-node": "^1.2.1", "browser-or-node": "^1.2.1",
"cvat-data": "../cvat-data", "cvat-data": "../cvat-data",
"detect-browser": "^5.2.0", "detect-browser": "^5.2.1",
"error-stack-parser": "^2.0.2", "error-stack-parser": "^2.0.2",
"form-data": "^2.5.0", "form-data": "^2.5.0",
"jest-config": "^26.6.3", "jest-config": "^26.6.3",
@ -90,7 +92,7 @@
"platform": "^1.3.5", "platform": "^1.3.5",
"quickhull": "^1.0.3", "quickhull": "^1.0.3",
"store": "^2.0.12", "store": "^2.0.12",
"worker-loader": "^2.0.0" "tus-js-client": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"coveralls": "^3.0.5", "coveralls": "^3.0.5",
@ -4694,6 +4696,21 @@
"react": "^16.3.0 || ^17" "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": { "node_modules/reactcss": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
@ -7939,6 +7956,8 @@
"cvat-canvas": { "cvat-canvas": {
"version": "file:../cvat-canvas", "version": "file:../cvat-canvas",
"requires": { "requires": {
"@types/polylabel": "^1.0.5",
"polylabel": "^1.1.0",
"svg.draggable.js": "2.2.2", "svg.draggable.js": "2.2.2",
"svg.draw.js": "^2.0.4", "svg.draw.js": "^2.0.4",
"svg.js": "2.7.1", "svg.js": "2.7.1",
@ -7961,7 +7980,7 @@
"browser-or-node": "^1.2.1", "browser-or-node": "^1.2.1",
"coveralls": "^3.0.5", "coveralls": "^3.0.5",
"cvat-data": "../cvat-data", "cvat-data": "../cvat-data",
"detect-browser": "^5.2.0", "detect-browser": "^5.2.1",
"error-stack-parser": "^2.0.2", "error-stack-parser": "^2.0.2",
"form-data": "^2.5.0", "form-data": "^2.5.0",
"jest": "^26.6.3", "jest": "^26.6.3",
@ -7973,7 +7992,7 @@
"platform": "^1.3.5", "platform": "^1.3.5",
"quickhull": "^1.0.3", "quickhull": "^1.0.3",
"store": "^2.0.12", "store": "^2.0.12",
"worker-loader": "^2.0.0" "tus-js-client": "^2.3.0"
} }
}, },
"cyclist": { "cyclist": {
@ -9880,6 +9899,16 @@
"jsonp": "^0.2.1" "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": { "reactcss": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",

@ -1,6 +1,6 @@
{ {
"name": "cvat-ui", "name": "cvat-ui",
"version": "1.35.2", "version": "1.36.0",
"description": "CVAT single-page application", "description": "CVAT single-page application",
"main": "src/index.tsx", "main": "src/index.tsx",
"scripts": { "scripts": {
@ -19,7 +19,6 @@
], ],
"author": "Intel", "author": "Intel",
"license": "MIT", "license": "MIT",
"devDependencies": {},
"dependencies": { "dependencies": {
"@ant-design/icons": "^4.6.3", "@ant-design/icons": "^4.6.3",
"@types/lodash": "^4.14.172", "@types/lodash": "^4.14.172",
@ -57,6 +56,7 @@
"react-router": "^5.1.0", "react-router": "^5.1.0",
"react-router-dom": "^5.1.0", "react-router-dom": "^5.1.0",
"react-share": "^4.4.0", "react-share": "^4.4.0",
"react-sortable-hoc": "^2.0.0",
"redux": "^4.1.1", "redux": "^4.1.1",
"redux-devtools-extension": "^2.13.9", "redux-devtools-extension": "^2.13.9",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",

@ -1,4 +1,4 @@
// Copyright (C) 2021 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -103,6 +103,13 @@ export type CloudStorageActions = ActionUnion<typeof cloudStoragesActions>;
export function getCloudStoragesAsync(query: Partial<CloudStoragesQuery>): ThunkAction { export function getCloudStoragesAsync(query: Partial<CloudStoragesQuery>): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => { return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
function camelToSnake(str: string): string {
return (
str[0].toLowerCase() + str.slice(1, str.length)
.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
);
}
dispatch(cloudStoragesActions.getCloudStorages()); dispatch(cloudStoragesActions.getCloudStorages());
dispatch(cloudStoragesActions.updateCloudStoragesGettingQuery(query)); dispatch(cloudStoragesActions.updateCloudStoragesGettingQuery(query));
@ -113,6 +120,23 @@ export function getCloudStoragesAsync(query: Partial<CloudStoragesQuery>): Thunk
} }
} }
// Temporary hack to do not change UI currently for cloud storages
// Will be redesigned in a different PR
const filter = {
and: ['displayName', 'resource', 'description', 'owner', 'providerType', 'credentialsType'].reduce<object[]>((acc, filterField) => {
if (filterField in filteredQuery) {
acc.push({ '==': [{ var: camelToSnake(filterField) }, filteredQuery[filterField]] });
delete filteredQuery[filterField];
}
return acc;
}, []),
};
if (filter.and.length) {
filteredQuery.filter = JSON.stringify(filter);
}
let result = null; let result = null;
try { try {
result = await cvat.cloudStorages.get(filteredQuery); result = await cvat.cloudStorages.get(filteredQuery);

@ -32,11 +32,10 @@ export const getJobsAsync = (query: JobsQuery): ThunkAction => async (dispatch)
try { try {
// Remove all keys with null values from the query // Remove all keys with null values from the query
const filteredQuery: Partial<JobsQuery> = { ...query }; const filteredQuery: Partial<JobsQuery> = { ...query };
for (const [key, value] of Object.entries(filteredQuery)) { if (filteredQuery.page === null) delete filteredQuery.page;
if (value === null) { if (filteredQuery.filter === null) delete filteredQuery.filter;
delete filteredQuery[key]; if (filteredQuery.sort === null) delete filteredQuery.sort;
} if (filteredQuery.search === null) delete filteredQuery.search;
}
dispatch(jobsActions.getJobs(filteredQuery)); dispatch(jobsActions.getJobs(filteredQuery));
const jobs = await cvat.jobs.get(filteredQuery); const jobs = await cvat.jobs.get(filteredQuery);

@ -1,4 +1,4 @@
// Copyright (C) 2019-2021 Intel Corporation // Copyright (C) 2019-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -113,6 +113,23 @@ export function getProjectsAsync(
} }
} }
// Temporary hack to do not change UI currently for projects
// Will be redesigned in a different PR
const filter = {
and: ['owner', 'assignee', 'name', 'status'].reduce<object[]>((acc, filterField) => {
if (filterField in filteredQuery) {
acc.push({ '==': [{ var: filterField }, filteredQuery[filterField]] });
delete filteredQuery[filterField];
}
return acc;
}, []),
};
if (filter.and.length) {
filteredQuery.filter = JSON.stringify(filter);
}
let result = null; let result = null;
try { try {
result = await cvat.projects.get(filteredQuery); result = await cvat.projects.get(filteredQuery);

@ -1,4 +1,4 @@
// Copyright (C) 2019-2021 Intel Corporation // Copyright (C) 2019-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -86,6 +86,23 @@ export function getTasksAsync(query: TasksQuery): ThunkAction<Promise<void>, {},
} }
} }
// Temporary hack to do not change UI currently for tasks
// Will be redesigned in a different PR
const filter = {
and: ['owner', 'assignee', 'name', 'status', 'mode', 'dimension'].reduce<object[]>((acc, filterField) => {
if (filterField in filteredQuery) {
acc.push({ '==': [{ var: filterField }, filteredQuery[filterField]] });
delete filteredQuery[filterField];
}
return acc;
}, []),
};
if (filter.and.length) {
filteredQuery.filter = JSON.stringify(filter);
}
let result = null; let result = null;
try { try {
result = await cvat.tasks.get(filteredQuery); result = await cvat.tasks.get(filteredQuery);

@ -1,4 +1,4 @@
// Copyright (C) 2021 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -95,7 +95,7 @@ export default function CreateCloudStorageForm(props: Props): JSX.Element {
display_name: cloudStorage.displayName, display_name: cloudStorage.displayName,
description: cloudStorage.description, description: cloudStorage.description,
provider_type: cloudStorage.providerType, provider_type: cloudStorage.providerType,
resource: cloudStorage.resourceName, resource: cloudStorage.resource,
manifests: manifestNames, manifests: manifestNames,
}; };

@ -5,7 +5,7 @@
import './styles.scss'; import './styles.scss';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { useHistory } from 'react-router'; import { useHistory, useLocation } from 'react-router';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import Icon, { import Icon, {
SettingOutlined, SettingOutlined,
@ -167,6 +167,7 @@ function HeaderContainer(props: Props): JSX.Element {
} = consts; } = consts;
const history = useHistory(); const history = useHistory();
const location = useLocation();
function showAboutModal(): void { function showAboutModal(): void {
Modal.info({ Modal.info({
@ -370,12 +371,18 @@ function HeaderContainer(props: Props): JSX.Element {
</Menu> </Menu>
); );
const getButtonClassName = (value: string): string => {
// eslint-disable-next-line security/detect-non-literal-regexp
const regex = new RegExp(`${value}$`);
return location.pathname.match(regex) ? 'cvat-header-button cvat-active-header-button' : 'cvat-header-button';
};
return ( return (
<Layout.Header className='cvat-header'> <Layout.Header className='cvat-header'>
<div className='cvat-left-header'> <div className='cvat-left-header'>
<Icon className='cvat-logo-icon' component={CVATLogo} /> <Icon className='cvat-logo-icon' component={CVATLogo} />
<Button <Button
className='cvat-header-button' className={getButtonClassName('projects')}
type='link' type='link'
value='projects' value='projects'
href='/projects?page=1' href='/projects?page=1'
@ -387,7 +394,7 @@ function HeaderContainer(props: Props): JSX.Element {
Projects Projects
</Button> </Button>
<Button <Button
className='cvat-header-button' className={getButtonClassName('tasks')}
type='link' type='link'
value='tasks' value='tasks'
href='/tasks?page=1' href='/tasks?page=1'
@ -399,7 +406,7 @@ function HeaderContainer(props: Props): JSX.Element {
Tasks Tasks
</Button> </Button>
<Button <Button
className='cvat-header-button' className={getButtonClassName('jobs')}
type='link' type='link'
value='jobs' value='jobs'
href='/jobs?page=1' href='/jobs?page=1'
@ -411,7 +418,7 @@ function HeaderContainer(props: Props): JSX.Element {
Jobs Jobs
</Button> </Button>
<Button <Button
className='cvat-header-button' className={getButtonClassName('cloudstorages')}
type='link' type='link'
value='cloudstorages' value='cloudstorages'
href='/cloudstorages?page=1' href='/cloudstorages?page=1'
@ -422,9 +429,9 @@ function HeaderContainer(props: Props): JSX.Element {
> >
Cloud Storages Cloud Storages
</Button> </Button>
{isModelsPluginActive && ( {isModelsPluginActive ? (
<Button <Button
className='cvat-header-button' className={getButtonClassName('models')}
type='link' type='link'
value='models' value='models'
href='/models' href='/models'
@ -435,8 +442,8 @@ function HeaderContainer(props: Props): JSX.Element {
> >
Models Models
</Button> </Button>
)} ) : null}
{isAnalyticsPluginActive && ( {isAnalyticsPluginActive ? (
<Button <Button
className='cvat-header-button' className='cvat-header-button'
type='link' type='link'
@ -450,7 +457,7 @@ function HeaderContainer(props: Props): JSX.Element {
> >
Analytics Analytics
</Button> </Button>
)} ) : null}
</div> </div>
<div className='cvat-right-header'> <div className='cvat-right-header'>
<CVATTooltip overlay='Click to open repository'> <CVATTooltip overlay='Click to open repository'>

@ -13,7 +13,22 @@
background: $header-color; background: $header-color;
} }
.ant-btn.cvat-header-button {
color: $text-color;
padding: 0 $grid-unit-size;
margin-right: $grid-unit-size;
}
.cvat-left-header { .cvat-left-header {
.ant-btn.cvat-header-button {
opacity: 0.7;
&.cvat-active-header-button {
font-weight: bold;
opacity: 1;
}
}
width: 50%; width: 50%;
display: flex; display: flex;
justify-content: flex-start; 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 { .cvat-header-menu-user-dropdown {
display: flex; display: flex;
align-items: center; align-items: center;

@ -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<Config>,
localStorageRecentKeyword: string,
localStorageRecentCapacity: number,
predefinedFilterValues: Record<string, string>,
defaultEnabledFilters: string[],
): React.FunctionComponent<ResourceFilterProps> {
const config: Config = { ...AntdConfig, ...filtrationCfg };
const defaultTree = QbUtils.checkTree(
QbUtils.loadTree({ id: QbUtils.uuid(), type: 'group' }), config,
) as ImmutableTree;
function keepFilterInLocalStorage(filter: string): void {
if (typeof filter !== 'string') {
return;
}
let savedItems: string[] = [];
try {
savedItems = JSON.parse(localStorage.getItem(localStorageRecentKeyword) || '[]');
if (!Array.isArray(savedItems) || savedItems.some((item: any) => typeof item !== 'string')) {
throw new Error('Wrong filters value stored');
}
} catch (_: any) {
// nothing to do
}
savedItems.splice(0, 0, filter);
savedItems = Array.from(new Set(savedItems)).slice(0, localStorageRecentCapacity);
localStorage.setItem(localStorageRecentKeyword, JSON.stringify(savedItems));
}
function receiveRecentFilters(): Record<string, string> {
let recentFilters: string[] = [];
try {
recentFilters = JSON.parse(localStorage.getItem(localStorageRecentKeyword) || '[]');
if (!Array.isArray(recentFilters) || recentFilters.some((item: any) => typeof item !== 'string')) {
throw new Error('Wrong filters value stored');
}
} catch (_: any) {
// nothing to do
}
return recentFilters
.reduce((acc: Record<string, string>, val: string) => ({ ...acc, [val]: val }), {});
}
const defaultAppliedFilter: {
predefined: string[] | null;
recent: string | null;
built: string | null;
} = {
predefined: null,
recent: null,
built: null,
};
function ResourceFilterComponent(props: ResourceFilterProps): JSX.Element {
const {
predefinedVisible, builderVisible, recentVisible,
onPredefinedVisibleChange, onBuilderVisibleChange, onRecentVisibleChange, onApplyFilter,
} = props;
const user = useSelector((state: CombinedState) => state.auth.user);
const [isMounted, setIsMounted] = useState<boolean>(false);
const [recentFilters, setRecentFilters] = useState<Record<string, string>>({});
const [predefinedFilters, setPredefinedFilters] = useState<Record<string, string>>({});
const [appliedFilter, setAppliedFilter] = useState<typeof defaultAppliedFilter>(defaultAppliedFilter);
const [state, setState] = useState<ImmutableTree>(defaultTree);
useEffect(() => {
setRecentFilters(receiveRecentFilters());
setIsMounted(true);
}, []);
useEffect(() => {
if (user) {
const result: Record<string, string> = {};
for (const key of Object.keys(predefinedFilterValues)) {
result[key] = predefinedFilterValues[key].replace('<username>', `${user.username}`);
}
setPredefinedFilters(result);
setAppliedFilter({
...appliedFilter,
predefined: defaultEnabledFilters
.filter((filterKey: string) => filterKey in result)
.map((filterKey: string) => result[filterKey]),
});
}
}, [user]);
useEffect(() => {
function unite(filters: string[]): string {
if (filters.length > 1) {
return JSON.stringify({
and: filters.map((filter: string): JSON => JSON.parse(filter)),
});
}
return filters[0];
}
function isValidTree(tree: ImmutableTree): boolean {
return (QbUtils.queryString(tree, config) || '').trim().length > 0 && QbUtils.isValidTree(tree);
}
if (!isMounted) {
// do not request jobs before until on mount hook is done
return;
}
if (appliedFilter.predefined?.length) {
onApplyFilter(unite(appliedFilter.predefined));
} else if (appliedFilter.recent) {
onApplyFilter(appliedFilter.recent);
const tree = QbUtils.loadFromJsonLogic(JSON.parse(appliedFilter.recent), config);
if (isValidTree(tree)) {
setState(tree);
}
} else if (appliedFilter.built) {
onApplyFilter(appliedFilter.built);
} else {
onApplyFilter(null);
setState(defaultTree);
}
}, [appliedFilter]);
const renderBuilder = (builderProps: any): JSX.Element => (
<div className='query-builder-container'>
<div className='query-builder qb-lite'>
<Builder {...builderProps} />
</div>
</div>
);
return (
<div className='cvat-jobs-page-filters'>
<Dropdown
destroyPopupOnHide
visible={predefinedVisible}
placement='bottomLeft'
overlay={(
<div className='cvat-jobs-page-predefined-filters-list'>
{Object.keys(predefinedFilters).map((key: string): JSX.Element => (
<Checkbox
checked={appliedFilter.predefined?.includes(predefinedFilters[key])}
onChange={(event: CheckboxChangeEvent) => {
let updatedValue: string[] | null = appliedFilter.predefined || [];
if (event.target.checked) {
updatedValue.push(predefinedFilters[key]);
} else {
updatedValue = updatedValue
.filter((appliedValue: string) => (
appliedValue !== predefinedFilters[key]
));
}
if (!updatedValue.length) {
updatedValue = null;
}
setAppliedFilter({
...defaultAppliedFilter,
predefined: updatedValue,
});
}}
key={key}
>
{key}
</Checkbox>
)) }
</div>
)}
>
<Button type='default' onClick={() => onPredefinedVisibleChange(!predefinedVisible)}>
Quick filters
{ appliedFilter.predefined ?
<FilterFilled /> :
<FilterOutlined />}
</Button>
</Dropdown>
<Dropdown
placement='bottomRight'
visible={builderVisible}
destroyPopupOnHide
overlay={(
<div className='cvat-jobs-page-filters-builder'>
{ Object.keys(recentFilters).length ? (
<Dropdown
placement='bottomRight'
visible={recentVisible}
destroyPopupOnHide
overlay={(
<div className='cvat-jobs-page-recent-filters-list'>
<Menu selectable={false}>
{Object.keys(recentFilters).map((key: string): JSX.Element | null => {
const tree = QbUtils.loadFromJsonLogic(JSON.parse(key), config);
if (!tree) {
return null;
}
return (
<Menu.Item
key={key}
onClick={() => {
if (appliedFilter.recent === key) {
setAppliedFilter(defaultAppliedFilter);
} else {
setAppliedFilter({
...defaultAppliedFilter,
recent: key,
});
}
}}
>
{QbUtils.queryString(tree, config)}
</Menu.Item>
);
})}
</Menu>
</div>
)}
>
<Button
size='small'
type='text'
onClick={
() => onRecentVisibleChange(!recentVisible)
}
>
Recent
<DownOutlined />
</Button>
</Dropdown>
) : null}
<Query
{...config}
onChange={(tree: ImmutableTree) => {
setState(tree);
}}
value={state}
renderBuilder={renderBuilder}
/>
<Space className='cvat-jobs-page-filters-space'>
<Button
disabled={!QbUtils.queryString(state, config)}
size='small'
onClick={() => {
setState(defaultTree);
setAppliedFilter({
...appliedFilter,
recent: null,
built: null,
});
}}
>
Reset
</Button>
<Button
size='small'
type='primary'
onClick={() => {
const filter = QbUtils.jsonLogicFormat(state, config).logic;
const stringified = JSON.stringify(filter);
keepFilterInLocalStorage(stringified);
setRecentFilters(receiveRecentFilters());
onBuilderVisibleChange(false);
setAppliedFilter({
predefined: null,
recent: null,
built: stringified,
});
}}
>
Apply
</Button>
</Space>
</div>
)}
>
<Button type='default' onClick={() => onBuilderVisibleChange(!builderVisible)}>
Filter
{ appliedFilter.built || appliedFilter.recent ?
<FilterFilled /> :
<FilterOutlined />}
</Button>
</Dropdown>
<Button
disabled={!(appliedFilter.built || appliedFilter.predefined || appliedFilter.recent)}
size='small'
type='link'
onClick={() => { setAppliedFilter({ ...defaultAppliedFilter }); }}
>
Clear filters
</Button>
</div>
);
}
return React.memo(ResourceFilterComponent);
}

@ -32,8 +32,14 @@ function JobCardComponent(props: Props): JSX.Element {
const [expanded, setExpanded] = useState<boolean>(false); const [expanded, setExpanded] = useState<boolean>(false);
const history = useHistory(); const history = useHistory();
const height = useCardHeight(); const height = useCardHeight();
const onClick = (): void => { const onClick = (event: React.MouseEvent): void => {
history.push(`/tasks/${job.taskId}/jobs/${job.id}`); 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 ( return (

@ -0,0 +1,121 @@
// Copyright (C) 2022 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { Config } from 'react-awesome-query-builder';
export const config: Partial<Config> = {
fields: {
state: {
label: 'State',
type: 'select',
operators: ['select_any_in', 'select_equals'], // ['select_equals', 'select_not_equals', 'select_any_in', 'select_not_any_in']
valueSources: ['value'],
fieldSettings: {
listValues: [
{ value: 'new', title: 'new' },
{ value: 'in progress', title: 'in progress' },
{ value: 'rejected', title: 'rejected' },
{ value: 'completed', title: 'completed' },
],
},
},
stage: {
label: 'Stage',
type: 'select',
operators: ['select_any_in', 'select_equals'],
valueSources: ['value'],
fieldSettings: {
listValues: [
{ value: 'annotation', title: 'annotation' },
{ value: 'validation', title: 'validation' },
{ value: 'acceptance', title: 'acceptance' },
],
},
},
dimension: {
label: 'Dimension',
type: 'select',
operators: ['select_equals'],
valueSources: ['value'],
fieldSettings: {
listValues: [
{ value: '2d', title: '2D' },
{ value: '3d', title: '3D' },
],
},
},
assignee: {
label: 'Assignee',
type: 'text', // todo: change to select
valueSources: ['value'],
fieldSettings: {
// useAsyncSearch: true,
// forceAsyncSearch: true,
// async fetch does not work for now in this library for AntdConfig
// but that issue was solved, see https://github.com/ukrbublik/react-awesome-query-builder/issues/616
// waiting for a new release, alternative is to use material design, but it is not the best option too
// asyncFetch: async (search: string | null) => {
// const users = await core.users.get({
// limit: 10,
// is_active: true,
// ...(search ? { search } : {}),
// });
// return {
// values: users.map((user: any) => ({
// value: user.username, title: user.username,
// })),
// hasMore: false,
// };
// },
},
},
updated_date: {
label: 'Last updated',
type: 'datetime',
operators: ['between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
},
id: {
label: 'ID',
type: 'number',
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
fieldSettings: { min: 0 },
valueSources: ['value'],
},
task_id: {
label: 'Task ID',
type: 'number',
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
fieldSettings: { min: 0 },
valueSources: ['value'],
},
project_id: {
label: 'Project ID',
type: 'number',
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
fieldSettings: { min: 0 },
valueSources: ['value'],
},
task_name: {
label: 'Task name',
type: 'text',
valueSources: ['value'],
operators: ['like'],
},
project_name: {
label: 'Project name',
type: 'text',
valueSources: ['value'],
operators: ['like'],
},
},
};
export const localStorageRecentCapacity = 10;
export const localStorageRecentKeyword = 'recentlyAppliedJobsFilters';
export const predefinedFilterValues = {
'Assigned to me': '{"and":[{"==":[{"var":"assignee"},"<username>"]}]}',
'Not completed': '{"!":{"or":[{"==":[{"var":"state"},"completed"]},{"==":[{"var":"stage"},"acceptance"]}]}}',
};
export const defaultEnabledFilters = ['Not completed'];

@ -3,8 +3,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import './styles.scss'; import './styles.scss';
import React, { useEffect } from 'react'; import React from 'react';
import { useHistory } from 'react-router';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import Spin from 'antd/lib/spin'; import Spin from 'antd/lib/spin';
import { Col, Row } from 'antd/lib/grid'; import { Col, Row } from 'antd/lib/grid';
@ -22,47 +21,6 @@ function JobsPageComponent(): JSX.Element {
const query = useSelector((state: CombinedState) => state.jobs.query); const query = useSelector((state: CombinedState) => state.jobs.query);
const fetching = useSelector((state: CombinedState) => state.jobs.fetching); const fetching = useSelector((state: CombinedState) => state.jobs.fetching);
const count = useSelector((state: CombinedState) => state.jobs.count); const count = useSelector((state: CombinedState) => state.jobs.count);
const history = useHistory();
useEffect(() => {
// get relevant query parameters from the url and fetch jobs according to them
const { location } = history;
const searchParams = new URLSearchParams(location.search);
const copiedQuery = { ...query };
for (const key of Object.keys(copiedQuery)) {
if (searchParams.has(key)) {
const value = searchParams.get(key);
if (value) {
copiedQuery[key] = key === 'page' ? +value : value;
}
} else {
copiedQuery[key] = null;
}
}
dispatch(getJobsAsync(copiedQuery));
}, []);
useEffect(() => {
// when query is updated, set relevant search params to url
const searchParams = new URLSearchParams();
const { location } = history;
for (const [key, value] of Object.entries(query)) {
if (value) {
searchParams.set(key, value.toString());
}
}
history.push(`${location.pathname}?${searchParams.toString()}`);
}, [query]);
if (fetching) {
return (
<div className='cvat-jobs-page'>
<Spin size='large' className='cvat-spinner' />
</div>
);
}
const dimensions = { const dimensions = {
md: 22, md: 22,
@ -71,43 +29,65 @@ function JobsPageComponent(): JSX.Element {
xxl: 16, xxl: 16,
}; };
const content = count ? (
<>
<JobsContentComponent />
<Row justify='space-around' about='middle'>
<Col {...dimensions}>
<Pagination
className='cvat-jobs-page-pagination'
onChange={(page: number) => {
dispatch(getJobsAsync({
...query,
page,
}));
}}
showSizeChanger={false}
total={count}
pageSize={12}
current={query.page}
showQuickJumper
/>
</Col>
</Row>
</>
) : <Empty />;
return ( return (
<div className='cvat-jobs-page'> <div className='cvat-jobs-page'>
<TopBarComponent <TopBarComponent
query={query} query={query}
onChangeFilters={(filters: Record<string, string | null>) => { onApplySearch={(search: string | null) => {
dispatch(
getJobsAsync({
...query,
search,
page: 1,
}),
);
}}
onApplyFilter={(filter: string | null) => {
dispatch( dispatch(
getJobsAsync({ getJobsAsync({
...query, ...query,
...filters, filter,
page: 1,
}),
);
}}
onApplySorting={(sorting: string | null) => {
dispatch(
getJobsAsync({
...query,
sort: sorting,
page: 1, page: 1,
}), }),
); );
}} }}
/> />
{count ? ( { fetching ? (
<> <Spin size='large' className='cvat-spinner' />
<JobsContentComponent /> ) : content }
<Row justify='space-around' about='middle'>
<Col {...dimensions}>
<Pagination
className='cvat-jobs-page-pagination'
onChange={(page: number) => {
dispatch(getJobsAsync({
...query,
page,
}));
}}
showSizeChanger={false}
total={count}
pageSize={12}
current={query.page}
showQuickJumper
/>
</Col>
</Row>
</>
) : <Empty />}
</div> </div>
); );

@ -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<string, string>;
setAppliedSorting: (arg: Record<string, string>) => void;
}): JSX.Element => {
const isActiveField = value in appliedSorting;
const isAscendingField = isActiveField && !appliedSorting[value]?.startsWith('-');
const isDescendingField = isActiveField && !isAscendingField;
const onClick = (): void => {
if (isDescendingField) {
setAppliedSorting({ ...appliedSorting, [value]: value });
} else if (isAscendingField) {
setAppliedSorting({ ...appliedSorting, [value]: `-${value}` });
}
};
if (value === ANCHOR_KEYWORD) {
return (
<hr className='cvat-sorting-anchor' />
);
}
return (
<div className='cvat-sorting-field'>
<Radio.Button disabled={valueIndex > anchorIndex}>{value}</Radio.Button>
<div>
<CVATTooltip overlay={appliedSorting[value]?.startsWith('-') ? 'Descending sort' : 'Ascending sort'}>
<Button type='text' disabled={!isActiveField} onClick={onClick}>
{
isDescendingField ? (
<SortDescendingOutlined />
) : (
<SortAscendingOutlined />
)
}
</Button>
</CVATTooltip>
</div>
</div>
);
},
);
const SortableList = SortableContainer(
({ items, appliedSorting, setAppliedSorting } :
{
items: string[];
appliedSorting: Record<string, string>;
setAppliedSorting: (arg: Record<string, string>) => void;
}) => (
<div className='cvat-jobs-page-sorting-list'>
{ items.map((value: string, index: number) => (
<SortableItem
key={`item-${value}`}
appliedSorting={appliedSorting}
setAppliedSorting={setAppliedSorting}
index={index}
value={value}
valueIndex={index}
anchorIndex={items.indexOf(ANCHOR_KEYWORD)}
/>
)) }
</div>
),
);
function SortingModalComponent(props: Props): JSX.Element {
const {
sortingFields: sortingFieldsProp,
defaultFields, visible, onApplySorting, onVisibleChange,
} = props;
const [appliedSorting, setAppliedSorting] = useState<Record<string, string>>(
defaultFields.reduce((acc: Record<string, string>, field: string) => {
const [isAscending, absField] = field.startsWith('-') ?
[false, field.slice(1).replace('_', ' ')] : [true, field.replace('_', ' ')];
const originalField = sortingFieldsProp.find((el: string) => el.toLowerCase() === absField.toLowerCase());
if (originalField) {
return { ...acc, [originalField]: isAscending ? originalField : `-${originalField}` };
}
return acc;
}, {}),
);
const [sortingFields, setSortingFields] = useState<string[]>(
Array.from(new Set([...Object.keys(appliedSorting), ANCHOR_KEYWORD, ...sortingFieldsProp])),
);
const [appliedOrder, setAppliedOrder] = useState<string[]>([...defaultFields]);
useEffect(() => {
const anchorIdx = sortingFields.indexOf(ANCHOR_KEYWORD);
const appliedSortingCopy = { ...appliedSorting };
const slicedSortingFields = sortingFields.slice(0, anchorIdx);
const updated = slicedSortingFields.length !== appliedOrder.length || slicedSortingFields
.some((field: string, index: number) => field !== appliedOrder[index]);
sortingFields.forEach((field: string, index: number) => {
if (index < anchorIdx && !(field in appliedSortingCopy)) {
appliedSortingCopy[field] = field;
} else if (index >= anchorIdx && field in appliedSortingCopy) {
delete appliedSortingCopy[field];
}
});
if (updated) {
setAppliedOrder(slicedSortingFields);
setAppliedSorting(appliedSortingCopy);
}
}, [sortingFields]);
useEffect(() => {
// this hook uses sortingFields to understand order
// but we do not specify this field in dependencies
// because we do not want the hook to be called after changing sortingField
// sortingField value is always relevant because if order changes, the hook before will be called first
const anchorIdx = sortingFields.indexOf(ANCHOR_KEYWORD);
const sortingString = sortingFields.slice(0, anchorIdx)
.map((field: string): string => appliedSorting[field])
.join(',').toLowerCase().replace(/\s/g, '_');
onApplySorting(sortingString || null);
}, [appliedSorting]);
return (
<Dropdown
destroyPopupOnHide
visible={visible}
placement='bottomLeft'
overlay={(
<SortableList
onSortEnd={({ oldIndex, newIndex }: { oldIndex: number, newIndex: number }) => {
if (oldIndex !== newIndex) {
const sortingFieldsCopy = [...sortingFields];
sortingFieldsCopy.splice(newIndex, 0, ...sortingFieldsCopy.splice(oldIndex, 1));
setSortingFields(sortingFieldsCopy);
}
}}
helperClass='cvat-sorting-dragged-item'
items={sortingFields}
appliedSorting={appliedSorting}
setAppliedSorting={setAppliedSorting}
/>
)}
>
<Button type='default' onClick={() => onVisibleChange(!visible)}>
Sort by
<OrderedListOutlined />
</Button>
</Dropdown>
);
}
export default React.memo(SortingModalComponent);

@ -10,22 +10,6 @@
height: 100%; height: 100%;
width: 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:nth-child(1) {
div > { div > {
.cvat-title { .cvat-title {
@ -128,19 +112,158 @@
right: $grid-unit-size; right: $grid-unit-size;
font-size: 16px; 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 { &:last-child {
.ant-table-cell { margin-right: 0;
width: $grid-unit-size * 15;
background: #f0f2f5;
} }
}
}
.ant-table-tbody { .cvat-jobs-page-recent-filters-list {
display: none; 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; padding: $grid-unit-size;
border-radius: 4px;
box-shadow: $box-shadow-base;
display: flex;
flex-direction: column;
align-items: flex-end;
// redefine default awesome react query builder styles below
.query-builder {
margin: $grid-unit-size;
.group.group-or-rule {
background: none !important;
border: none !important;
}
.group--actions.group--actions--tr {
opacity: 1 !important;
}
.group--conjunctions {
div.ant-btn-group {
button.ant-btn {
width: auto !important;
opacity: 1 !important;
margin-right: $grid-unit-size !important;
padding: 0 $grid-unit-size !important;
}
}
}
}
}
.cvat-jobs-page-sorting-list,
.cvat-jobs-page-predefined-filters-list,
.cvat-jobs-page-recent-filters-list {
background: white;
padding: $grid-unit-size;
border-radius: 4px;
display: flex;
flex-direction: column;
box-shadow: $box-shadow-base;
.ant-checkbox-wrapper {
margin-bottom: $grid-unit-size;
margin-left: 0;
}
}
.cvat-jobs-page-sorting-list {
width: $grid-unit-size * 24;
}
.cvat-sorting-field {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $grid-unit-size;
.ant-radio-button-wrapper {
width: $grid-unit-size * 16;
user-select: none;
cursor: move;
}
}
.cvat-sorting-anchor {
width: 100%;
pointer-events: none;
&:first-child {
margin-top: $grid-unit-size * 4;
}
&:last-child {
margin-bottom: $grid-unit-size * 4;
}
}
.cvat-sorting-dragged-item {
z-index: 10000;
}
.cvat-jobs-page-filters-space {
justify-content: right;
align-items: center;
display: flex;
}
.cvat-jobs-page-top-bar {
> div {
display: flex;
justify-content: space-between;
> div {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.cvat-jobs-page-search-bar {
width: $grid-unit-size * 32;
padding-left: $grid-unit-size * 0.5;
}
> div {
> *:not(:last-child) {
margin-right: $grid-unit-size;
}
display: flex;
}
}
}
} }

@ -2,101 +2,88 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import React from 'react'; import React, { useState } from 'react';
import { Col, Row } from 'antd/lib/grid'; import { Col, Row } from 'antd/lib/grid';
import Text from 'antd/lib/typography/Text'; import Input from 'antd/lib/input';
import Table from 'antd/lib/table';
import { FilterValue, TablePaginationConfig } from 'antd/lib/table/interface';
import { JobsQuery } from 'reducers/interfaces'; import { JobsQuery } from 'reducers/interfaces';
import UserSelector, { User } from 'components/task-page/user-selector'; import SortingComponent from './sorting';
import Button from 'antd/lib/button'; 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 { interface Props {
onChangeFilters(filters: Record<string, string | null>): void;
query: JobsQuery; query: JobsQuery;
onApplyFilter(filter: string | null): void;
onApplySorting(sorting: string | null): void;
onApplySearch(search: string | null): void;
} }
function TopBarComponent(props: Props): JSX.Element { function TopBarComponent(props: Props): JSX.Element {
const { query, onChangeFilters } = props; const {
query, onApplyFilter, onApplySorting, onApplySearch,
const columns = [ } = props;
{ const [visibility, setVisibility] = useState<typeof defaultVisibility>(defaultVisibility);
title: 'Stage',
dataIndex: 'stage',
key: 'stage',
filteredValue: query.stage?.split(',') || null,
className: `${query.stage ? 'cvat-jobs-page-filter cvat-jobs-page-filter-active' : 'cvat-jobs-page-filter'}`,
filters: [
{ text: 'annotation', value: 'annotation' },
{ text: 'validation', value: 'validation' },
{ text: 'acceptance', value: 'acceptance' },
],
},
{
title: 'State',
dataIndex: 'state',
key: 'state',
filteredValue: query.state?.split(',') || null,
className: `${query.state ? 'cvat-jobs-page-filter cvat-jobs-page-filter-active' : 'cvat-jobs-page-filter'}`,
filters: [
{ text: 'new', value: 'new' },
{ text: 'in progress', value: 'in progress' },
{ text: 'completed', value: 'completed' },
{ text: 'rejected', value: 'rejected' },
],
},
{
title: 'Assignee',
dataIndex: 'assignee',
key: 'assignee',
filteredValue: query.assignee ? [query.assignee] : null,
className: `${query.assignee ? 'cvat-jobs-page-filter cvat-jobs-page-filter-active' : 'cvat-jobs-page-filter'}`,
filterDropdown: (
<div className='cvat-jobs-filter-dropdown-users'>
<UserSelector
username={query.assignee ? query.assignee : undefined}
value={null}
onSelect={(value: User | null): void => {
if (value) {
if (query.assignee !== value.username) {
onChangeFilters({ assignee: value.username });
}
} else if (query.assignee !== null) {
onChangeFilters({ assignee: null });
}
}}
/>
<Button disabled={query.assignee === null} type='link' onClick={() => onChangeFilters({ assignee: null })}>
Reset
</Button>
</div>
),
},
];
return ( return (
<Row className='cvat-jobs-page-top-bar' justify='center' align='middle'> <Row className='cvat-jobs-page-top-bar' justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={16}> <Col md={22} lg={18} xl={16} xxl={16}>
<Row justify='space-between' align='bottom'> <div>
<Col> <Input.Search
<Text className='cvat-title'>Jobs</Text> enterButton
</Col> onSearch={(phrase: string) => {
<Table onApplySearch(phrase);
onChange={(_: TablePaginationConfig, filters: Record<string, FilterValue | null>) => {
const processed = Object.fromEntries(
Object.entries(filters)
.map(([key, values]) => (
[key, typeof values === 'string' || values === null ? values : values.join(',')]
)),
);
onChangeFilters(processed);
}} }}
className='cvat-jobs-page-filters' defaultValue={query.search || ''}
columns={columns} className='cvat-jobs-page-search-bar'
size='small' placeholder='Search ..'
/> />
</Row> <div>
<SortingComponent
visible={visibility.sorting}
onVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, sorting: visible })
)}
defaultFields={query.sort?.split(',') || ['ID']}
sortingFields={['ID', 'Assignee', 'Updated date', 'Stage', 'State', 'Task ID', 'Project ID', 'Task name', 'Project name']}
onApplySorting={onApplySorting}
/>
<FilteringComponent
predefinedVisible={visibility.predefined}
builderVisible={visibility.builder}
recentVisible={visibility.recent}
onPredefinedVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, predefined: visible })
)}
onBuilderVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, builder: visible })
)}
onRecentVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, builder: visibility.builder, recent: visible })
)}
onApplyFilter={onApplyFilter}
/>
</div>
</div>
</Col> </Col>
</Row> </Row>
); );

@ -1,4 +1,4 @@
// Copyright (C) 2021 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -51,7 +51,7 @@ export default function SearchTooltip(props: Props): JSX.Element {
) : null} ) : null}
{instance === 'cloudstorage' ? ( {instance === 'cloudstorage' ? (
<Paragraph> <Paragraph>
<Text strong>resourceName: mycvatbucket</Text> <Text strong>resource: mycvatbucket</Text>
<Text> <Text>
all all
{instances} {instances}

@ -1,4 +1,4 @@
// Copyright (C) 2021 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -20,7 +20,7 @@ const defaultState: CloudStoragesState = {
owner: null, owner: null,
displayName: null, displayName: null,
description: null, description: null,
resourceName: null, resource: null,
providerType: null, providerType: null,
credentialsType: null, credentialsType: null,
status: null, status: null,

@ -70,6 +70,7 @@ export interface TasksQuery {
name: string | null; name: string | null;
status: string | null; status: string | null;
mode: string | null; mode: string | null;
filter: string | null;
projectId: number | null; projectId: number | null;
[key: string]: string | number | null; [key: string]: string | number | null;
} }
@ -81,10 +82,9 @@ export interface Task {
export interface JobsQuery { export interface JobsQuery {
page: number; page: number;
assignee: string | null; sort: string | null;
stage: 'annotation' | 'validation' | 'acceptance' | null; search: string | null;
state: 'new' | 'in progress' | 'rejected' | 'completed' | null; filter: string | null;
[index: string]: number | null | string | undefined;
} }
export interface JobsState { export interface JobsState {
@ -159,7 +159,7 @@ export interface CloudStoragesQuery {
owner: string | null; owner: string | null;
displayName: string | null; displayName: string | null;
description: string | null; description: string | null;
resourceName: string | null; resource: string | null;
providerType: string | null; providerType: string | null;
credentialsType: string | null; credentialsType: string | null;
[key: string]: string | number | null | undefined; [key: string]: string | number | null | undefined;

@ -10,9 +10,8 @@ const defaultState: JobsState = {
count: 0, count: 0,
query: { query: {
page: 1, page: 1,
state: null, filter: null,
stage: null, sort: null,
assignee: null,
}, },
current: [], current: [],
previews: [], previews: [],

@ -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

@ -22,7 +22,6 @@ from django.contrib.auth.models import User
from django.db import IntegrityError from django.db import IntegrityError
from django.http import HttpResponse, HttpResponseNotFound, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseNotFound, HttpResponseBadRequest
from django.utils import timezone from django.utils import timezone
from django_filters import rest_framework as filters
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import ( from drf_spectacular.utils import (
@ -48,9 +47,9 @@ from cvat.apps.engine.frame_provider import FrameProvider
from cvat.apps.engine.media_extractors import ImageListReader from cvat.apps.engine.media_extractors import ImageListReader
from cvat.apps.engine.mime_types import mimetypes from cvat.apps.engine.mime_types import mimetypes
from cvat.apps.engine.models import ( from cvat.apps.engine.models import (
Job, StatusChoice, Task, Project, Issue, Data, Job, Task, Project, Issue, Data,
Comment, StorageMethodChoice, StorageChoice, Image, Comment, StorageMethodChoice, StorageChoice, Image,
CredentialsTypeChoice, CloudProviderChoice CloudProviderChoice
) )
from cvat.apps.engine.models import CloudStorage as CloudStorageModel from cvat.apps.engine.models import CloudStorage as CloudStorageModel
from cvat.apps.engine.serializers import ( from cvat.apps.engine.serializers import (
@ -221,31 +220,8 @@ class ServerViewSet(viewsets.ViewSet):
} }
return Response(response) 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( @extend_schema_view(list=extend_schema(
summary='Returns a paginated list of projects according to query parameters (12 projects per page)', 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={ responses={
'200': PolymorphicProxySerializer(component_name='PolymorphicProject', '200': PolymorphicProxySerializer(component_name='PolymorphicProject',
serializers=[ serializers=[
@ -277,18 +253,19 @@ class ProjectViewSet(viewsets.ModelViewSet):
queryset=models.Label.objects.order_by('id') queryset=models.Label.objects.order_by('id')
)) ))
search_fields = ("name", "owner__username", "assignee__username", "status") # NOTE: The search_fields attribute should be a list of names of text
filterset_class = ProjectFilter # type fields on the model,such as CharField or TextField
ordering_fields = ("id", "name", "owner", "status", "assignee") search_fields = ('name', 'owner', 'assignee', 'status')
ordering = ("-id",) 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') http_method_names = ('get', 'post', 'head', 'patch', 'delete')
iam_organization_field = 'organization' iam_organization_field = 'organization'
def get_serializer_class(self): def get_serializer_class(self):
if self.request.path.endswith('tasks'): if self.request.path.endswith('tasks'):
return TaskSerializer return TaskSerializer
if self.request.query_params and self.request.query_params.get("names_only") == "true":
return ProjectSearchSerializer
else: else:
return ProjectSerializer return ProjectSerializer
@ -550,35 +527,8 @@ class DataChunkGetter:
return Response(data='unknown data type {}.'.format(self.type), return Response(data='unknown data type {}.'.format(self.type),
status=status.HTTP_400_BAD_REQUEST) 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( @extend_schema_view(list=extend_schema(
summary='Returns a paginated list of tasks according to query parameters (10 tasks per page)', 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={ responses={
'200': TaskSerializer(many=True), '200': TaskSerializer(many=True),
}, tags=['tasks'], versions=['2.0'])) }, tags=['tasks'], versions=['2.0']))
@ -609,12 +559,13 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet):
queryset = Task.objects.prefetch_related( queryset = Task.objects.prefetch_related(
Prefetch('label_set', queryset=models.Label.objects.order_by('id')), Prefetch('label_set', queryset=models.Label.objects.order_by('id')),
"label_set__attributespec_set", "label_set__attributespec_set",
"segment_set__job_set", "segment_set__job_set")
).order_by('-id')
serializer_class = TaskSerializer serializer_class = TaskSerializer
search_fields = ("name", "owner__username", "mode", "status") lookup_fields = {'project_name': 'project__name', 'owner': 'owner__username', 'assignee': 'assignee__username'}
filterset_class = TaskFilter search_fields = ('project_name', 'name', 'owner', 'status', 'assignee', 'subset', 'mode', 'dimension')
ordering_fields = ("id", "name", "owner", "status", "assignee", "subset") filter_fields = list(search_fields) + ['id', 'project_id']
ordering_fields = filter_fields
ordering = "-id"
iam_organization_field = 'organization' iam_organization_field = 'organization'
def get_queryset(self): def get_queryset(self):
@ -956,18 +907,6 @@ class TaskViewSet(UploadMixin, viewsets.ModelViewSet):
filename=request.query_params.get("filename", "").lower(), 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( @extend_schema_view(retrieve=extend_schema(
summary='Method returns details of a job', summary='Method returns details of a job',
responses={ responses={
@ -990,12 +929,25 @@ class JobFilter(filters.FilterSet):
}, tags=['jobs'], versions=['2.0'])) }, tags=['jobs'], versions=['2.0']))
class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.UpdateModelMixin): mixins.RetrieveModelMixin, mixins.UpdateModelMixin):
queryset = Job.objects.all().order_by('id') queryset = Job.objects.all()
filterset_class = JobFilter
iam_organization_field = 'segment__task__organization' 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): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
if self.action == 'list': if self.action == 'list':
perm = JobPermission.create_list(self.request) perm = JobPermission.create_list(self.request)
queryset = perm.filter(queryset) queryset = perm.filter(queryset)
@ -1039,7 +991,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
data = dm.task.get_job_data(pk) data = dm.task.get_job_data(pk)
return Response(data) return Response(data)
elif request.method == 'PUT': elif request.method == 'PUT':
format_name = request.query_params.get("format", "") format_name = request.query_params.get('format', '')
if format_name: if format_name:
return _import_annotations( return _import_annotations(
request=request, request=request,
@ -1147,6 +1099,16 @@ class IssueViewSet(viewsets.ModelViewSet):
queryset = Issue.objects.all().order_by('-id') queryset = Issue.objects.all().order_by('-id')
http_method_names = ['get', 'post', 'patch', 'delete', 'options'] http_method_names = ['get', 'post', 'patch', 'delete', 'options']
iam_organization_field = 'job__segment__task__organization' 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): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
@ -1212,6 +1174,11 @@ class CommentViewSet(viewsets.ModelViewSet):
queryset = Comment.objects.all().order_by('-id') queryset = Comment.objects.all().order_by('-id')
http_method_names = ['get', 'post', 'patch', 'delete', 'options'] http_method_names = ['get', 'post', 'patch', 'delete', 'options']
iam_organization_field = 'issue__job__segment__task__organization' 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): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
@ -1230,19 +1197,8 @@ class CommentViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(owner=self.request.user) serializer.save(owner=self.request.user)
class UserFilter(filters.FilterSet):
class Meta:
model = User
fields = ("id", "is_active")
@extend_schema_view(list=extend_schema( @extend_schema_view(list=extend_schema(
summary='Method provides a paginated list of users registered on the server', 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={ responses={
'200': PolymorphicProxySerializer(component_name='MetaUser', '200': PolymorphicProxySerializer(component_name='MetaUser',
serializers=[ serializers=[
@ -1272,12 +1228,15 @@ class UserFilter(filters.FilterSet):
}, tags=['users'], versions=['2.0'])) }, tags=['users'], versions=['2.0']))
class UserViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, class UserViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin): 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'] http_method_names = ['get', 'post', 'head', 'patch', 'delete']
search_fields = ('username', 'first_name', 'last_name') search_fields = ('username', 'first_name', 'last_name')
filterset_class = UserFilter
iam_organization_field = 'memberships__organization' iam_organization_field = 'memberships__organization'
filter_fields = ('id', 'is_active', 'username')
ordering_fields = filter_fields
ordering = "-id"
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
if self.action == 'list': if self.action == 'list':
@ -1314,29 +1273,6 @@ class UserViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
serializer = serializer_class(request.user, context={ "request": request }) serializer = serializer_class(request.user, context={ "request": request })
return Response(serializer.data) 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( @extend_schema_view(retrieve=extend_schema(
summary='Method returns details of a specific cloud storage', summary='Method returns details of a specific cloud storage',
responses={ responses={
@ -1344,20 +1280,6 @@ class CloudStorageFilter(filters.FilterSet):
}, tags=['cloud storages'], versions=['2.0'])) }, tags=['cloud storages'], versions=['2.0']))
@extend_schema_view(list=extend_schema( @extend_schema_view(list=extend_schema(
summary='Returns a paginated list of storages according to query parameters', 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={ responses={
'200': CloudStorageReadSerializer(many=True), '200': CloudStorageReadSerializer(many=True),
}, tags=['cloud storages'], versions=['2.0'])) }, tags=['cloud storages'], versions=['2.0']))
@ -1368,23 +1290,24 @@ class CloudStorageFilter(filters.FilterSet):
}, tags=['cloud storages'], versions=['2.0'])) }, tags=['cloud storages'], versions=['2.0']))
@extend_schema_view(partial_update=extend_schema( @extend_schema_view(partial_update=extend_schema(
summary='Methods does a partial update of chosen fields in a cloud storage instance', summary='Methods does a partial update of chosen fields in a cloud storage instance',
# FIXME
#field_inspectors=[RedefineDescriptionField]
responses={ responses={
'200': CloudStorageWriteSerializer, '200': CloudStorageWriteSerializer,
}, tags=['cloud storages'], versions=['2.0'])) }, tags=['cloud storages'], versions=['2.0']))
@extend_schema_view(create=extend_schema( @extend_schema_view(create=extend_schema(
summary='Method creates a cloud storage with a specified characteristics', summary='Method creates a cloud storage with a specified characteristics',
# FIXME
#field_inspectors=[RedefineDescriptionField],
responses={ responses={
'201': CloudStorageWriteSerializer, '201': CloudStorageWriteSerializer,
}, tags=['cloud storages'], versions=['2.0'])) }, tags=['cloud storages'], versions=['2.0']))
class CloudStorageViewSet(viewsets.ModelViewSet): class CloudStorageViewSet(viewsets.ModelViewSet):
http_method_names = ['get', 'post', 'patch', 'delete'] http_method_names = ['get', 'post', 'patch', 'delete']
queryset = CloudStorageModel.objects.all().prefetch_related('data').order_by('-id') queryset = CloudStorageModel.objects.all().prefetch_related('data')
search_fields = ('provider_type', 'display_name', 'resource', 'credentials_type', 'owner__username', 'description')
filterset_class = CloudStorageFilter 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' iam_organization_field = 'organization'
def get_serializer_class(self): def get_serializer_class(self):

@ -5,7 +5,6 @@
from rest_framework import mixins, viewsets from rest_framework import mixins, viewsets
from rest_framework.permissions import SAFE_METHODS from rest_framework.permissions import SAFE_METHODS
from django.utils.crypto import get_random_string 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 from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
@ -18,7 +17,6 @@ from .serializers import (
MembershipReadSerializer, MembershipWriteSerializer, MembershipReadSerializer, MembershipWriteSerializer,
OrganizationReadSerializer, OrganizationWriteSerializer) OrganizationReadSerializer, OrganizationWriteSerializer)
@extend_schema_view(retrieve=extend_schema( @extend_schema_view(retrieve=extend_schema(
summary='Method returns details of an organization', summary='Method returns details of an organization',
responses={ responses={
@ -51,7 +49,11 @@ from .serializers import (
}, tags=['organizations'], versions=['2.0'])) }, tags=['organizations'], versions=['2.0']))
class OrganizationViewSet(viewsets.ModelViewSet): class OrganizationViewSet(viewsets.ModelViewSet):
queryset = Organization.objects.all() 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'] http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
pagination_class = None pagination_class = None
iam_organization_field = None iam_organization_field = None
@ -73,9 +75,6 @@ class OrganizationViewSet(viewsets.ModelViewSet):
extra_kwargs.update({ 'name': serializer.validated_data['slug'] }) extra_kwargs.update({ 'name': serializer.validated_data['slug'] })
serializer.save(**extra_kwargs) serializer.save(**extra_kwargs)
class MembershipFilter(filters.FilterSet):
user = filters.CharFilter(field_name="user__id")
class Meta: class Meta:
model = Membership model = Membership
fields = ("user", ) fields = ("user", )
@ -107,9 +106,12 @@ class MembershipFilter(filters.FilterSet):
class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin, class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin,
mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet): mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
queryset = Membership.objects.all() queryset = Membership.objects.all()
ordering = ['-id'] ordering = '-id'
http_method_names = ['get', 'patch', 'delete', 'head', 'options'] 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' iam_organization_field = 'organization'
def get_serializer_class(self): def get_serializer_class(self):
@ -156,10 +158,15 @@ class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin,
}, tags=['invitations'], versions=['2.0'])) }, tags=['invitations'], versions=['2.0']))
class InvitationViewSet(viewsets.ModelViewSet): class InvitationViewSet(viewsets.ModelViewSet):
queryset = Invitation.objects.all() queryset = Invitation.objects.all()
ordering = ['-created_date']
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
iam_organization_field = 'membership__organization' 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): def get_serializer_class(self):
if self.request.method in SAFE_METHODS: if self.request.method in SAFE_METHODS:
return InvitationReadSerializer return InvitationReadSerializer

@ -111,7 +111,6 @@ INSTALLED_APPS = [
'dj_pagination', 'dj_pagination',
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'django_filters',
'drf_spectacular', 'drf_spectacular',
'rest_auth', 'rest_auth',
'django.contrib.sites', 'django.contrib.sites',
@ -163,11 +162,12 @@ REST_FRAMEWORK = {
'cvat.apps.engine.pagination.CustomPagination', 'cvat.apps.engine.pagination.CustomPagination',
'PAGE_SIZE': 10, 'PAGE_SIZE': 10,
'DEFAULT_FILTER_BACKENDS': ( 'DEFAULT_FILTER_BACKENDS': (
'rest_framework.filters.SearchFilter', 'cvat.apps.engine.filters.SearchFilter',
'django_filters.rest_framework.DjangoFilterBackend', 'cvat.apps.engine.filters.OrderingFilter',
'rest_framework.filters.OrderingFilter', 'cvat.apps.engine.filters.JsonLogicFilter',
'cvat.apps.iam.filters.OrganizationFilterBackend'), 'cvat.apps.iam.filters.OrganizationFilterBackend'),
'SEARCH_PARAM': 'search',
# Disable default handling of the 'format' query parameter by REST framework # Disable default handling of the 'format' query parameter by REST framework
'URL_FORMAT_OVERRIDE': 'scheme', 'URL_FORMAT_OVERRIDE': 'scheme',
'DEFAULT_THROTTLE_CLASSES': [ 'DEFAULT_THROTTLE_CLASSES': [

@ -29,6 +29,7 @@ context('Search task feature.', () => {
cy.assignTaskToUser(''); cy.assignTaskToUser('');
}); });
// TODO: rework this test
describe(`Testing case "${caseId}"`, () => { describe(`Testing case "${caseId}"`, () => {
it('Tooltip task filter contain all the possible options.', () => { it('Tooltip task filter contain all the possible options.', () => {
cy.get('.cvat-search-field').trigger('mouseover'); cy.get('.cvat-search-field').trigger('mouseover');

@ -4,29 +4,23 @@
"previous": null, "previous": null,
"results": [ "results": [
{ {
"assignee": { "assignee": null,
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"bug_tracker": null, "bug_tracker": null,
"data_chunk_size": 72, "data_chunk_size": 72,
"data_compressed_chunk_type": "imageset", "data_compressed_chunk_type": "imageset",
"dimension": "2d", "dimension": "2d",
"id": 1, "id": 9,
"labels": [ "labels": [
{ {
"attributes": [], "attributes": [],
"color": "#6080c0", "color": "#6080c0",
"id": 1, "id": 11,
"name": "cat" "name": "cat"
}, },
{ {
"attributes": [], "attributes": [],
"color": "#406040", "color": "#406040",
"id": 2, "id": 12,
"name": "dog" "name": "dog"
} }
], ],
@ -34,48 +28,104 @@
"project_id": null, "project_id": null,
"stage": "annotation", "stage": "annotation",
"start_frame": 0, "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", "state": "new",
"status": "annotation", "status": "annotation",
"stop_frame": 129, "stop_frame": 0,
"task_id": 1, "task_id": 6,
"url": "http://localhost:8080/api/jobs/1" "url": "http://localhost:8080/api/jobs/8"
}, },
{ {
"assignee": { "assignee": {
"first_name": "Worker", "first_name": "Worker",
"id": 6, "id": 9,
"last_name": "First", "last_name": "Fourth",
"url": "http://localhost:8080/api/users/6", "url": "http://localhost:8080/api/users/9",
"username": "worker1" "username": "worker4"
}, },
"bug_tracker": null, "bug_tracker": null,
"data_chunk_size": 72, "data_chunk_size": 72,
"data_compressed_chunk_type": "imageset", "data_compressed_chunk_type": "imageset",
"dimension": "2d", "dimension": "2d",
"id": 2, "id": 7,
"labels": [ "labels": [
{ {
"attributes": [], "attributes": [],
"color": "#2080c0", "color": "#2080c0",
"id": 3, "id": 9,
"name": "car" "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": [], "attributes": [],
"color": "#c06060", "color": "#406040",
"id": 4, "id": 8,
"name": "person" "name": "dog"
} }
], ],
"mode": "annotation", "mode": "annotation",
"project_id": null, "project_id": 2,
"stage": "annotation", "stage": "annotation",
"start_frame": 0, "start_frame": 0,
"state": "new", "state": "new",
"status": "annotation", "status": "annotation",
"stop_frame": 22, "stop_frame": 57,
"task_id": 2, "task_id": 4,
"url": "http://localhost:8080/api/jobs/2" "url": "http://localhost:8080/api/jobs/6"
}, },
{ {
"assignee": null, "assignee": null,
@ -83,7 +133,7 @@
"data_chunk_size": 72, "data_chunk_size": 72,
"data_compressed_chunk_type": "imageset", "data_compressed_chunk_type": "imageset",
"dimension": "2d", "dimension": "2d",
"id": 3, "id": 5,
"labels": [ "labels": [
{ {
"attributes": [ "attributes": [
@ -113,13 +163,13 @@
], ],
"mode": "annotation", "mode": "annotation",
"project_id": 1, "project_id": 1,
"stage": "annotation", "stage": "acceptance",
"start_frame": 0, "start_frame": 100,
"state": "in progress", "state": "new",
"status": "annotation", "status": "validation",
"stop_frame": 49, "stop_frame": 147,
"task_id": 3, "task_id": 3,
"url": "http://localhost:8080/api/jobs/3" "url": "http://localhost:8080/api/jobs/5"
}, },
{ {
"assignee": null, "assignee": null,
@ -171,7 +221,7 @@
"data_chunk_size": 72, "data_chunk_size": 72,
"data_compressed_chunk_type": "imageset", "data_compressed_chunk_type": "imageset",
"dimension": "2d", "dimension": "2d",
"id": 5, "id": 3,
"labels": [ "labels": [
{ {
"attributes": [ "attributes": [
@ -201,95 +251,39 @@
], ],
"mode": "annotation", "mode": "annotation",
"project_id": 1, "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", "stage": "annotation",
"start_frame": 0, "start_frame": 0,
"state": "new", "state": "in progress",
"status": "annotation", "status": "annotation",
"stop_frame": 57, "stop_frame": 49,
"task_id": 4, "task_id": 3,
"url": "http://localhost:8080/api/jobs/6" "url": "http://localhost:8080/api/jobs/3"
}, },
{ {
"assignee": { "assignee": {
"first_name": "Worker", "first_name": "Worker",
"id": 9, "id": 6,
"last_name": "Fourth", "last_name": "First",
"url": "http://localhost:8080/api/users/9", "url": "http://localhost:8080/api/users/6",
"username": "worker4" "username": "worker1"
}, },
"bug_tracker": null, "bug_tracker": null,
"data_chunk_size": 72, "data_chunk_size": 72,
"data_compressed_chunk_type": "imageset", "data_compressed_chunk_type": "imageset",
"dimension": "2d", "dimension": "2d",
"id": 7, "id": 2,
"labels": [ "labels": [
{ {
"attributes": [], "attributes": [],
"color": "#2080c0", "color": "#2080c0",
"id": 9, "id": 3,
"name": "car" "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": [], "attributes": [],
"color": "#2080c0", "color": "#c06060",
"id": 10, "id": 4,
"name": "car" "name": "person"
} }
], ],
"mode": "annotation", "mode": "annotation",
@ -298,28 +292,34 @@
"start_frame": 0, "start_frame": 0,
"state": "new", "state": "new",
"status": "annotation", "status": "annotation",
"stop_frame": 0, "stop_frame": 22,
"task_id": 6, "task_id": 2,
"url": "http://localhost:8080/api/jobs/8" "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, "bug_tracker": null,
"data_chunk_size": 72, "data_chunk_size": 72,
"data_compressed_chunk_type": "imageset", "data_compressed_chunk_type": "imageset",
"dimension": "2d", "dimension": "2d",
"id": 9, "id": 1,
"labels": [ "labels": [
{ {
"attributes": [], "attributes": [],
"color": "#6080c0", "color": "#6080c0",
"id": 11, "id": 1,
"name": "cat" "name": "cat"
}, },
{ {
"attributes": [], "attributes": [],
"color": "#406040", "color": "#406040",
"id": 12, "id": 2,
"name": "dog" "name": "dog"
} }
], ],
@ -327,11 +327,11 @@
"project_id": null, "project_id": null,
"stage": "annotation", "stage": "annotation",
"start_frame": 0, "start_frame": 0,
"state": "in progress", "state": "new",
"status": "annotation", "status": "annotation",
"stop_frame": 10, "stop_frame": 129,
"task_id": 7, "task_id": 1,
"url": "http://localhost:8080/api/jobs/9" "url": "http://localhost:8080/api/jobs/1"
} }
] ]
} }

@ -4,164 +4,140 @@
"previous": null, "previous": null,
"results": [ "results": [
{ {
"date_joined": "2021-12-14T18:04:57Z", "date_joined": "2022-02-24T20:45:19Z",
"email": "admin1@cvat.org", "email": "user6@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",
"first_name": "User", "first_name": "User",
"groups": [ "groups": [
"user" "user"
], ],
"id": 2, "id": 20,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": "2022-02-16T06:24:53.910205Z", "last_login": null,
"last_name": "First", "last_name": "Sixth",
"url": "http://localhost:8080/api/users/2", "url": "http://localhost:8080/api/users/20",
"username": "user1" "username": "user6"
}, },
{ {
"date_joined": "2021-12-14T18:24:12Z", "date_joined": "2022-02-24T20:45:07Z",
"email": "user2@cvat.org", "email": "user5@cvat.org",
"first_name": "User", "first_name": "User",
"groups": [ "groups": [
"user" "user"
], ],
"id": 3, "id": 19,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Second", "last_name": "Fifth",
"url": "http://localhost:8080/api/users/3", "url": "http://localhost:8080/api/users/19",
"username": "user2" "username": "user5"
}, },
{ {
"date_joined": "2021-12-14T18:24:39Z", "date_joined": "2021-12-14T18:38:46Z",
"email": "user3@cvat.org", "email": "admin2@cvat.org",
"first_name": "User", "first_name": "Admin",
"groups": [ "groups": [
"user" "admin"
], ],
"id": 4, "id": 18,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": true,
"is_superuser": false, "is_superuser": true,
"last_login": null, "last_login": null,
"last_name": "Third", "last_name": "Second",
"url": "http://localhost:8080/api/users/4", "url": "http://localhost:8080/api/users/18",
"username": "user3" "username": "admin2"
}, },
{ {
"date_joined": "2021-12-14T18:25:10Z", "date_joined": "2021-12-14T18:37:41Z",
"email": "user4@cvat.org", "email": "dummy4@cvat.org",
"first_name": "User", "first_name": "Dummy",
"groups": [ "groups": [],
"user" "id": 17,
],
"id": 5,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Fourth", "last_name": "Fourth",
"url": "http://localhost:8080/api/users/5", "url": "http://localhost:8080/api/users/17",
"username": "user4" "username": "dummy4"
}, },
{ {
"date_joined": "2021-12-14T18:30:00Z", "date_joined": "2021-12-14T18:37:09Z",
"email": "worker1@cvat.org", "email": "dummy3@cvat.org",
"first_name": "Worker", "first_name": "Dummy",
"groups": [ "groups": [],
"worker" "id": 16,
],
"id": 6,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": "2021-12-14T19:11:21.048740Z", "last_login": null,
"last_name": "First", "last_name": "Third",
"url": "http://localhost:8080/api/users/6", "url": "http://localhost:8080/api/users/16",
"username": "worker1" "username": "dummy3"
}, },
{ {
"date_joined": "2021-12-14T18:30:43Z", "date_joined": "2021-12-14T18:36:31Z",
"email": "worker2@cvat.org", "email": "dummy2@cvat.org",
"first_name": "Worker", "first_name": "Dummy",
"groups": [ "groups": [],
"worker" "id": 15,
],
"id": 7,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Second", "last_name": "Second",
"url": "http://localhost:8080/api/users/7", "url": "http://localhost:8080/api/users/15",
"username": "worker2" "username": "dummy2"
}, },
{ {
"date_joined": "2021-12-14T18:31:25Z", "date_joined": "2021-12-14T18:36:00Z",
"email": "worker3@cvat.org", "email": "dummy1@cvat.org",
"first_name": "Worker", "first_name": "Dummy",
"groups": [ "groups": [],
"worker" "id": 14,
],
"id": 8,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Third", "last_name": "First",
"url": "http://localhost:8080/api/users/8", "url": "http://localhost:8080/api/users/14",
"username": "worker3" "username": "dummy1"
}, },
{ {
"date_joined": "2021-12-14T18:32:01Z", "date_joined": "2021-12-14T18:35:15Z",
"email": "worker4@cvat.org", "email": "business4@cvat.org",
"first_name": "Worker", "first_name": "Business",
"groups": [ "groups": [
"worker" "business"
], ],
"id": 9, "id": 13,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Fourth", "last_name": "Fourth",
"url": "http://localhost:8080/api/users/9", "url": "http://localhost:8080/api/users/13",
"username": "worker4" "username": "business4"
}, },
{ {
"date_joined": "2021-12-14T18:33:06Z", "date_joined": "2021-12-14T18:34:34Z",
"email": "business1@cvat.org", "email": "business3@cvat.org",
"first_name": "Business", "first_name": "Business",
"groups": [ "groups": [
"business" "business"
], ],
"id": 10, "id": 12,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": "2022-01-19T13:52:59.477881Z", "last_login": null,
"last_name": "First", "last_name": "Third",
"url": "http://localhost:8080/api/users/10", "url": "http://localhost:8080/api/users/12",
"username": "business1" "username": "business3"
}, },
{ {
"date_joined": "2021-12-14T18:34:01Z", "date_joined": "2021-12-14T18:34:01Z",
@ -180,140 +156,164 @@
"username": "business2" "username": "business2"
}, },
{ {
"date_joined": "2021-12-14T18:34:34Z", "date_joined": "2021-12-14T18:33:06Z",
"email": "business3@cvat.org", "email": "business1@cvat.org",
"first_name": "Business", "first_name": "Business",
"groups": [ "groups": [
"business" "business"
], ],
"id": 12, "id": 10,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": "2022-01-19T13:52:59.477881Z",
"last_name": "Third", "last_name": "First",
"url": "http://localhost:8080/api/users/12", "url": "http://localhost:8080/api/users/10",
"username": "business3" "username": "business1"
}, },
{ {
"date_joined": "2021-12-14T18:35:15Z", "date_joined": "2021-12-14T18:32:01Z",
"email": "business4@cvat.org", "email": "worker4@cvat.org",
"first_name": "Business", "first_name": "Worker",
"groups": [ "groups": [
"business" "worker"
], ],
"id": 13, "id": 9,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Fourth", "last_name": "Fourth",
"url": "http://localhost:8080/api/users/13", "url": "http://localhost:8080/api/users/9",
"username": "business4" "username": "worker4"
}, },
{ {
"date_joined": "2021-12-14T18:36:00Z", "date_joined": "2021-12-14T18:31:25Z",
"email": "dummy1@cvat.org", "email": "worker3@cvat.org",
"first_name": "Dummy", "first_name": "Worker",
"groups": [], "groups": [
"id": 14, "worker"
],
"id": 8,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "First", "last_name": "Third",
"url": "http://localhost:8080/api/users/14", "url": "http://localhost:8080/api/users/8",
"username": "dummy1" "username": "worker3"
}, },
{ {
"date_joined": "2021-12-14T18:36:31Z", "date_joined": "2021-12-14T18:30:43Z",
"email": "dummy2@cvat.org", "email": "worker2@cvat.org",
"first_name": "Dummy", "first_name": "Worker",
"groups": [], "groups": [
"id": 15, "worker"
],
"id": 7,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Second", "last_name": "Second",
"url": "http://localhost:8080/api/users/15", "url": "http://localhost:8080/api/users/7",
"username": "dummy2" "username": "worker2"
}, },
{ {
"date_joined": "2021-12-14T18:37:09Z", "date_joined": "2021-12-14T18:30:00Z",
"email": "dummy3@cvat.org", "email": "worker1@cvat.org",
"first_name": "Dummy", "first_name": "Worker",
"groups": [], "groups": [
"id": 16, "worker"
],
"id": 6,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": "2021-12-14T19:11:21.048740Z",
"last_name": "Third", "last_name": "First",
"url": "http://localhost:8080/api/users/16", "url": "http://localhost:8080/api/users/6",
"username": "dummy3" "username": "worker1"
}, },
{ {
"date_joined": "2021-12-14T18:37:41Z", "date_joined": "2021-12-14T18:25:10Z",
"email": "dummy4@cvat.org", "email": "user4@cvat.org",
"first_name": "Dummy", "first_name": "User",
"groups": [], "groups": [
"id": 17, "user"
],
"id": 5,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Fourth", "last_name": "Fourth",
"url": "http://localhost:8080/api/users/17", "url": "http://localhost:8080/api/users/5",
"username": "dummy4" "username": "user4"
}, },
{ {
"date_joined": "2021-12-14T18:38:46Z", "date_joined": "2021-12-14T18:24:39Z",
"email": "admin2@cvat.org", "email": "user3@cvat.org",
"first_name": "Admin", "first_name": "User",
"groups": [ "groups": [
"admin" "user"
], ],
"id": 18, "id": 4,
"is_active": true, "is_active": true,
"is_staff": true, "is_staff": false,
"is_superuser": true, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Second", "last_name": "Third",
"url": "http://localhost:8080/api/users/18", "url": "http://localhost:8080/api/users/4",
"username": "admin2" "username": "user3"
}, },
{ {
"date_joined": "2022-02-24T20:45:07Z", "date_joined": "2021-12-14T18:24:12Z",
"email": "user5@cvat.org", "email": "user2@cvat.org",
"first_name": "User", "first_name": "User",
"groups": [ "groups": [
"user" "user"
], ],
"id": 19, "id": 3,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": null,
"last_name": "Fifth", "last_name": "Second",
"url": "http://localhost:8080/api/users/19", "url": "http://localhost:8080/api/users/3",
"username": "user5" "username": "user2"
}, },
{ {
"date_joined": "2022-02-24T20:45:19Z", "date_joined": "2021-12-14T18:21:09Z",
"email": "user6@cvat.org", "email": "user1@cvat.org",
"first_name": "User", "first_name": "User",
"groups": [ "groups": [
"user" "user"
], ],
"id": 20, "id": 2,
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": null, "last_login": "2022-02-16T06:24:53.910205Z",
"last_name": "Sixth", "last_name": "First",
"url": "http://localhost:8080/api/users/20", "url": "http://localhost:8080/api/users/2",
"username": "user6" "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"
} }
] ]
} }

@ -234,4 +234,10 @@ def find_job_staff_user(is_job_staff):
if is_staff == is_job_staff(user['id'], job['id']): if is_staff == is_job_staff(user['id'], job['id']):
return user['username'], job['id'] return user['username'], job['id']
return None, None 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 return find

@ -24,4 +24,4 @@ def test_check_objects_integrity(path):
resp_objs = response.json() resp_objs = response.json()
assert DeepDiff(json_objs, resp_objs, ignore_order=True, assert DeepDiff(json_objs, resp_objs, ignore_order=True,
exclude_regex_paths="root\['results'\]\[\d+\]\['last_login'\]") == {} exclude_regex_paths=r"root\['results'\]\[d+\]\['last_login'\]") == {}

@ -110,7 +110,6 @@ class TestListJobs:
else: else:
self._test_list_jobs_403(user['username'], **kwargs) self._test_list_jobs_403(user['username'], **kwargs)
class TestGetAnnotations: class TestGetAnnotations:
def _test_get_job_annotations_200(self, user, jid, data, **kwargs): def _test_get_job_annotations_200(self, user, jid, data, **kwargs):
response = get_method(user, f'jobs/{jid}/annotations', **kwargs) response = get_method(user, f'jobs/{jid}/annotations', **kwargs)
@ -177,7 +176,6 @@ class TestGetAnnotations:
else: else:
self._test_get_job_annotations_403(username, job_id, **kwargs) self._test_get_job_annotations_403(username, job_id, **kwargs)
class TestPatchJobAnnotations: class TestPatchJobAnnotations:
_ORG = 2 _ORG = 2
@ -205,10 +203,11 @@ class TestPatchJobAnnotations:
('supervisor', True, True), ('worker', True, True) ('supervisor', True, True), ('worker', True, True)
]) ])
def test_member_update_job_annotations(self, org, role, job_staff, is_allow, 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) users = find_users(role=role, org=org)
jobs = jobs_by_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) data = request_data(jid)
response = patch_method(username, f'jobs/{jid}/annotations', response = patch_method(username, f'jobs/{jid}/annotations',
@ -222,10 +221,11 @@ class TestPatchJobAnnotations:
('admin', True), ('business', False), ('worker', False), ('user', False) ('admin', True), ('business', False), ('worker', False), ('user', False)
]) ])
def test_non_member_update_job_annotations(self, org, privilege, is_allow, 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) users = find_users(privilege=privilege, exclude_org=org)
jobs = jobs_by_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) data = request_data(jid)
response = patch_method(username, f'jobs/{jid}/annotations', data, response = patch_method(username, f'jobs/{jid}/annotations', data,
@ -241,10 +241,11 @@ class TestPatchJobAnnotations:
('user', True, True), ('user', False, False) ('user', True, True), ('user', False, False)
]) ])
def test_user_update_job_annotations(self, org, privilege, job_staff, is_allow, 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) users = find_users(privilege=privilege)
jobs = jobs_by_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) data = request_data(jid)
response = patch_method(username, f'jobs/{jid}/annotations', data, response = patch_method(username, f'jobs/{jid}/annotations', data,

Loading…
Cancel
Save