diff --git a/.gitignore b/.gitignore index cbe91f69a9c3..e985f4efe802 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ yarn-debug.log* yarn-error.log* .DS_Store + +#Ignore Cypress tests temp files +/tests/cypress/fixtures +/tests/cypress/screenshots diff --git a/.vscode/launch.json b/.vscode/launch.json index 48bc45835ce0..d6e8e878dec2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -21,6 +21,21 @@ }, "smartStep": true, }, + { + "type": "node", + "request": "launch", + "name": "ui.js: test", + "cwd": "${workspaceRoot}/tests", + "runtimeExecutable": "${workspaceRoot}/tests/node_modules/.bin/cypress", + "args": [ + "run", + "--headless", + "--browser", + "chrome" + ], + "outputCapture": "std", + "console": "internalConsole" + }, { "name": "server: django", "type": "python", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f87acdcba3a..c1e56bda54e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- +- Added basic projects implementation () + ### Changed diff --git a/cvat-canvas/.eslintignore b/cvat-canvas/.eslintignore new file mode 100644 index 000000000000..6de001d87b3e --- /dev/null +++ b/cvat-canvas/.eslintignore @@ -0,0 +1 @@ +webpack.config.js diff --git a/cvat-core/.eslintignore b/cvat-core/.eslintignore new file mode 100644 index 000000000000..6de001d87b3e --- /dev/null +++ b/cvat-core/.eslintignore @@ -0,0 +1 @@ +webpack.config.js diff --git a/cvat-core/src/api-implementation.js b/cvat-core/src/api-implementation.js index f7f9a1108b4a..3826873a87d5 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -16,6 +16,7 @@ const { AnnotationFormats } = require('./annotation-formats'); const { ArgumentError } = require('./exceptions'); const { Task } = require('./session'); + const { Project } = require('./project'); function implementAPI(cvat) { cvat.plugins.list.implementation = PluginRegistry.list; @@ -100,6 +101,11 @@ return result; }; + cvat.server.installedApps.implementation = async () => { + const result = await serverProxy.server.installedApps(); + return result; + }; + cvat.users.get.implementation = async (filter) => { checkFilter(filter, { id: isInteger, @@ -163,6 +169,7 @@ cvat.tasks.get.implementation = async (filter) => { checkFilter(filter, { page: isInteger, + projectId: isInteger, name: isString, id: isInteger, owner: isString, @@ -184,8 +191,15 @@ } } + if ( + 'projectId' in filter + && (('page' in filter && Object.keys(filter).length > 2) || Object.keys(filter).length > 2) + ) { + throw new ArgumentError('Do not use the filter field "projectId" with other'); + } + const searchParams = new URLSearchParams(); - for (const field of ['name', 'owner', 'assignee', 'search', 'status', 'mode', 'id', 'page']) { + for (const field of ['name', 'owner', 'assignee', 'search', 'status', 'mode', 'id', 'page', 'projectId']) { if (Object.prototype.hasOwnProperty.call(filter, field)) { searchParams.set(field, filter[field]); } @@ -199,11 +213,47 @@ return tasks; }; - cvat.server.installedApps.implementation = async () => { - const result = await serverProxy.server.installedApps(); - return result; + cvat.projects.get.implementation = async (filter) => { + checkFilter(filter, { + id: isInteger, + page: isInteger, + name: isString, + assignee: isString, + owner: isString, + search: isString, + status: isEnum.bind(TaskStatus), + }); + + if ('search' in filter && Object.keys(filter).length > 1) { + if (!('page' in filter && Object.keys(filter).length === 2)) { + throw new ArgumentError('Do not use the filter field "search" with others'); + } + } + + if ('id' in filter && Object.keys(filter).length > 1) { + if (!('page' in filter && Object.keys(filter).length === 2)) { + throw new ArgumentError('Do not use the filter field "id" with others'); + } + } + + const searchParams = new URLSearchParams(); + for (const field of ['name', 'assignee', 'owner', 'search', 'status', 'id', 'page']) { + if (Object.prototype.hasOwnProperty.call(filter, field)) { + searchParams.set(field, filter[field]); + } + } + + const projectsData = await serverProxy.projects.get(searchParams.toString()); + // prettier-ignore + const projects = projectsData.map((project) => new Project(project)); + + projects.count = projectsData.count; + + return projects; }; + cvat.projects.searchNames.implementation = async (search, limit) => serverProxy.projects.searchNames(search, limit); + return cvat; } diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index db330f629b85..a33ec2825939 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -14,6 +14,7 @@ function build() { const ObjectState = require('./object-state'); const Statistics = require('./statistics'); const { Job, Task } = require('./session'); + const { Project } = require('./project'); const { Attribute, Label } = require('./labels'); const MLModel = require('./ml-model'); @@ -274,6 +275,60 @@ function build() { return result; }, }, + /** + * Namespace is used for getting projects + * @namespace projects + * @memberof module:API.cvat + */ + projects: { + /** + * @typedef {Object} ProjectFilter + * @property {string} name Check if name contains this value + * @property {module:API.cvat.enums.ProjectStatus} status + * Check if status contains this value + * @property {integer} id Check if id equals this value + * @property {integer} page Get specific page + * (default REST API returns 20 projects per request. + * In order to get more, it is need to specify next page) + * @property {string} owner Check if owner user contains this value + * @property {string} search Combined search of contains among all fields + * @global + */ + + /** + * Method returns list of projects corresponding to a filter + * @method get + * @async + * @memberof module:API.cvat.projects + * @param {ProjectFilter} [filter={}] project filter + * @returns {module:API.cvat.classes.Project[]} + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + */ + async get(filter = {}) { + const result = await PluginRegistry.apiWrapper(cvat.projects.get, filter); + return result; + }, + + /** + * Method returns list of project names with project ids + * corresponding to a search phrase + * used for autocomplete field + * @method searchNames + * @async + * @memberof module:API.cvat.projects + * @param {string} [search = ''] search phrase + * @param {number} [limit = 10] number of returning project names + * @returns {module:API.cvat.classes.Project[]} + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + * + */ + async searchNames(search = '', limit = 10) { + const result = await PluginRegistry.apiWrapper(cvat.projects.searchNames, search, limit); + return result; + }, + }, /** * Namespace is used for getting tasks * @namespace tasks @@ -291,6 +346,7 @@ function build() { * @property {integer} page Get specific page * (default REST API returns 20 tasks per request. * In order to get more, it is need to specify next page) + * @property {integer} projectId Check if project_id field contains this value * @property {string} owner Check if owner user contains this value * @property {string} assignee Check if assigneed contains this value * @property {string} search Combined search of contains among all fields @@ -717,8 +773,9 @@ function build() { * @memberof module:API.cvat */ classes: { - Task, User, + Project, + Task, Job, Log, Attribute, @@ -730,6 +787,7 @@ function build() { }; cvat.server = Object.freeze(cvat.server); + cvat.projects = Object.freeze(cvat.projects); cvat.tasks = Object.freeze(cvat.tasks); cvat.jobs = Object.freeze(cvat.jobs); cvat.users = Object.freeze(cvat.users); diff --git a/cvat-core/src/project.js b/cvat-core/src/project.js new file mode 100644 index 000000000000..d53d5eece927 --- /dev/null +++ b/cvat-core/src/project.js @@ -0,0 +1,265 @@ +// Copyright (C) 2019-2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +(() => { + const PluginRegistry = require('./plugins'); + const serverProxy = require('./server-proxy'); + const { ArgumentError } = require('./exceptions'); + const { Task } = require('./session'); + const { Label } = require('./labels'); + const User = require('./user'); + + /** + * Class representing a project + * @memberof module:API.cvat.classes + */ + class Project { + /** + * In a fact you need use the constructor only if you want to create a project + * @param {object} initialData - Object which is used for initalization + *
It can contain keys: + *
  • name + *
  • labels + */ + constructor(initialData) { + const data = { + id: undefined, + name: undefined, + status: undefined, + assignee: undefined, + owner: undefined, + bug_tracker: undefined, + created_date: undefined, + updated_date: undefined, + }; + + for (const property in data) { + if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) { + data[property] = initialData[property]; + } + } + + data.labels = []; + data.tasks = []; + + if (Array.isArray(initialData.labels)) { + for (const label of initialData.labels) { + const classInstance = new Label(label); + data.labels.push(classInstance); + } + } + + if (Array.isArray(initialData.tasks)) { + for (const task of initialData.tasks) { + const taskInstance = new Task(task); + data.tasks.push(taskInstance); + } + } + + Object.defineProperties( + this, + Object.freeze({ + /** + * @name id + * @type {integer} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + id: { + get: () => data.id, + }, + /** + * @name name + * @type {string} + * @memberof module:API.cvat.classes.Project + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + name: { + get: () => data.name, + set: (value) => { + if (!value.trim().length) { + throw new ArgumentError('Value must not be empty'); + } + data.name = value; + }, + }, + /** + * @name status + * @type {module:API.cvat.enums.TaskStatus} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + status: { + get: () => data.status, + }, + /** + * Instance of a user who was assigned for the project + * @name assignee + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + assignee: { + get: () => data.assignee, + set: (assignee) => { + if (assignee !== null && !(assignee instanceof User)) { + throw new ArgumentError('Value must be a user instance'); + } + data.assignee = assignee; + }, + }, + /** + * Instance of a user who has created the project + * @name owner + * @type {module:API.cvat.classes.User} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + owner: { + get: () => data.owner, + }, + /** + * @name bugTracker + * @type {string} + * @memberof module:API.cvat.classes.Project + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + bugTracker: { + get: () => data.bug_tracker, + set: (tracker) => { + data.bug_tracker = tracker; + }, + }, + /** + * @name createdDate + * @type {string} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + createdDate: { + get: () => data.created_date, + }, + /** + * @name updatedDate + * @type {string} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + updatedDate: { + get: () => data.updated_date, + }, + /** + * After project has been created value can be appended only. + * @name labels + * @type {module:API.cvat.classes.Label[]} + * @memberof module:API.cvat.classes.Project + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + labels: { + get: () => [...data.labels], + set: (labels) => { + if (!Array.isArray(labels)) { + throw new ArgumentError('Value must be an array of Labels'); + } + + if (!Array.isArray(labels) || labels.some((label) => !(label instanceof Label))) { + throw new ArgumentError( + `Each array value must be an instance of Label. ${typeof label} was found`, + ); + } + + data.labels = [...labels]; + }, + }, + /** + * Tasks linked with the project + * @name tasks + * @type {module:API.cvat.classes.Task[]} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + */ + tasks: { + get: () => [...data.tasks], + }, + }), + ); + } + + /** + * Method updates data of a created project or creates new project from scratch + * @method save + * @returns {module:API.cvat.classes.Project} + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async save() { + const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.save); + return result; + } + + /** + * Method deletes a task from a server + * @method delete + * @memberof module:API.cvat.classes.Project + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.ServerError} + * @throws {module:API.cvat.exceptions.PluginError} + */ + async delete() { + const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.delete); + return result; + } + } + + module.exports = { + Project, + }; + + Project.prototype.save.implementation = async function () { + if (typeof this.id !== 'undefined') { + const projectData = { + name: this.name, + assignee_id: this.assignee ? this.assignee.id : null, + bug_tracker: this.bugTracker, + labels: [...this.labels.map((el) => el.toJSON())], + }; + + await serverProxy.projects.save(this.id, projectData); + return this; + } + + const projectSpec = { + name: this.name, + labels: [...this.labels.map((el) => el.toJSON())], + }; + + if (this.bugTracker) { + projectSpec.bug_tracker = this.bugTracker; + } + + const project = await serverProxy.projects.create(projectSpec); + return new Project(project); + }; + + Project.prototype.delete.implementation = async function () { + const result = await serverProxy.projects.delete(this.id); + return result; + }; +})(); diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js index 524defe76303..71e1cfbebf13 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -312,6 +312,82 @@ } } + async function searchProjectNames(search, limit) { + const { backendAPI, proxy } = config; + + let response = null; + try { + response = await Axios.get( + `${backendAPI}/projects?names_only=true&page=1&page_size=${limit}&search=${search}`, + { + proxy, + }, + ); + } catch (errorData) { + throw generateError(errorData); + } + + response.data.results.count = response.data.count; + return response.data.results; + } + + async function getProjects(filter = '') { + const { backendAPI, proxy } = config; + + let response = null; + try { + response = await Axios.get(`${backendAPI}/projects?page_size=12&${filter}`, { + proxy, + }); + } catch (errorData) { + throw generateError(errorData); + } + + response.data.results.count = response.data.count; + return response.data.results; + } + + async function saveProject(id, projectData) { + const { backendAPI } = config; + + try { + await Axios.patch(`${backendAPI}/projects/${id}`, JSON.stringify(projectData), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + } + + async function deleteProject(id) { + const { backendAPI } = config; + + try { + await Axios.delete(`${backendAPI}/projects/${id}`); + } catch (errorData) { + throw generateError(errorData); + } + } + + async function createProject(projectSpec) { + const { backendAPI } = config; + + try { + const response = await Axios.post(`${backendAPI}/projects`, JSON.stringify(projectSpec), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + return response.data; + } catch (errorData) { + throw generateError(errorData); + } + } + async function getTasks(filter = '') { const { backendAPI } = config; @@ -347,7 +423,12 @@ const { backendAPI } = config; try { - await Axios.delete(`${backendAPI}/tasks/${id}`); + await Axios.delete(`${backendAPI}/tasks/${id}`, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); } catch (errorData) { throw generateError(errorData); } @@ -827,6 +908,17 @@ writable: false, }, + projects: { + value: Object.freeze({ + get: getProjects, + searchNames: searchProjectNames, + save: saveProject, + create: createProject, + delete: deleteProject, + }), + writable: false, + }, + tasks: { value: Object.freeze({ getTasks, diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index fba1a2ab1e0b..e9ee3b8806e5 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -871,6 +871,7 @@ const data = { id: undefined, name: undefined, + project_id: undefined, status: undefined, size: undefined, mode: undefined, @@ -972,6 +973,16 @@ data.name = value; }, }, + /** + * @name projectId + * @type {integer|null} + * @memberof module:API.cvat.classes.Task + * @readonly + * @instance + */ + projectId: { + get: () => data.project_id, + }, /** * @name status * @type {module:API.cvat.enums.TaskStatus} @@ -1697,7 +1708,7 @@ return this; }; - Task.prototype.save.implementation = async function saveTaskImplementation(onUpdate) { + Task.prototype.save.implementation = async function (onUpdate) { // TODO: Add ability to change an owner and an assignee if (typeof this.id !== 'undefined') { // If the task has been already created, we update it @@ -1750,6 +1761,9 @@ if (typeof this.overlap !== 'undefined') { taskSpec.overlap = this.overlap; } + if (typeof this.projectId !== 'undefined') { + taskSpec.project_id = this.projectId; + } const taskDataSpec = { client_files: this.clientFiles, diff --git a/cvat-core/tests/api/projects.js b/cvat-core/tests/api/projects.js new file mode 100644 index 000000000000..5c1374301884 --- /dev/null +++ b/cvat-core/tests/api/projects.js @@ -0,0 +1,170 @@ +// Copyright (C) 2019-2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +// Setup mock for a server +jest.mock('../../src/server-proxy', () => { + const mock = require('../mocks/server-proxy.mock'); + return mock; +}); + +// Initialize api +window.cvat = require('../../src/api'); + +const { Task } = require('../../src/session'); +const { Project } = require('../../src/project'); + +describe('Feature: get projects', () => { + test('get all projects', async () => { + const result = await window.cvat.projects.get(); + expect(Array.isArray(result)).toBeTruthy(); + expect(result).toHaveLength(2); + for (const el of result) { + expect(el).toBeInstanceOf(Project); + } + }); + + test('get project by id', async () => { + const result = await window.cvat.projects.get({ + id: 2, + }); + + expect(Array.isArray(result)).toBeTruthy(); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Project); + expect(result[0].id).toBe(2); + expect(result[0].tasks).toHaveLength(1); + expect(result[0].tasks[0]).toBeInstanceOf(Task); + }); + + test('get a project by an unknown id', async () => { + const result = await window.cvat.projects.get({ + id: 1, + }); + expect(Array.isArray(result)).toBeTruthy(); + expect(result).toHaveLength(0); + }); + + test('get a project by an invalid id', async () => { + expect( + window.cvat.projects.get({ + id: '1', + }), + ).rejects.toThrow(window.cvat.exceptions.ArgumentError); + }); + + test('get projects by filters', async () => { + const result = await window.cvat.projects.get({ + status: 'completed', + }); + expect(Array.isArray(result)).toBeTruthy(); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Project); + expect(result[0].id).toBe(2); + expect(result[0].status).toBe('completed'); + }); + + test('get projects by invalid filters', async () => { + expect( + window.cvat.projects.get({ + unknown: '5', + }), + ).rejects.toThrow(window.cvat.exceptions.ArgumentError); + }); +}); + +describe('Feature: save a project', () => { + test('save some changed fields in a project', async () => { + let result = await window.cvat.tasks.get({ + id: 2, + }); + + result[0].bugTracker = 'newBugTracker'; + result[0].name = 'New Project Name'; + + result[0].save(); + + result = await window.cvat.tasks.get({ + id: 2, + }); + + expect(result[0].bugTracker).toBe('newBugTracker'); + expect(result[0].name).toBe('New Project Name'); + }); + + test('save some new labels in a project', async () => { + let result = await window.cvat.projects.get({ + id: 6, + }); + + const labelsLength = result[0].labels.length; + const newLabel = new window.cvat.classes.Label({ + name: "My boss's car", + attributes: [ + { + default_value: 'false', + input_type: 'checkbox', + mutable: true, + name: 'parked', + values: ['false'], + }, + ], + }); + + result[0].labels = [...result[0].labels, newLabel]; + result[0].save(); + + result = await window.cvat.projects.get({ + id: 6, + }); + + expect(result[0].labels).toHaveLength(labelsLength + 1); + const appendedLabel = result[0].labels.filter((el) => el.name === "My boss's car"); + expect(appendedLabel).toHaveLength(1); + expect(appendedLabel[0].attributes).toHaveLength(1); + expect(appendedLabel[0].attributes[0].name).toBe('parked'); + expect(appendedLabel[0].attributes[0].defaultValue).toBe('false'); + expect(appendedLabel[0].attributes[0].mutable).toBe(true); + expect(appendedLabel[0].attributes[0].inputType).toBe('checkbox'); + }); + + test('save new project without an id', async () => { + const project = new window.cvat.classes.Project({ + name: 'New Empty Project', + labels: [ + { + name: 'car', + attributes: [ + { + default_value: 'false', + input_type: 'checkbox', + mutable: true, + name: 'parked', + values: ['false'], + }, + ], + }, + ], + bug_tracker: 'bug tracker value', + }); + + const result = await project.save(); + expect(typeof result.id).toBe('number'); + }); +}); + +describe('Feature: delete a project', () => { + test('delete a project', async () => { + let result = await window.cvat.projects.get({ + id: 6, + }); + + await result[0].delete(); + result = await window.cvat.projects.get({ + id: 6, + }); + + expect(Array.isArray(result)).toBeTruthy(); + expect(result).toHaveLength(0); + }); +}); diff --git a/cvat-core/tests/api/tasks.js b/cvat-core/tests/api/tasks.js index 897dba51fb90..c309b4150753 100644 --- a/cvat-core/tests/api/tasks.js +++ b/cvat-core/tests/api/tasks.js @@ -166,6 +166,19 @@ describe('Feature: save a task', () => { const result = await task.save(); expect(typeof result.id).toBe('number'); }); + + test('save new task in project', async () => { + const task = new window.cvat.classes.Task({ + name: 'New Task', + project_id: 2, + bug_tracker: 'bug tracker value', + image_quality: 50, + z_order: true, + }); + + const result = await task.save(); + expect(result.projectId).toBe(2); + }); }); describe('Feature: delete a task', () => { diff --git a/cvat-core/tests/mocks/dummy-data.mock.js b/cvat-core/tests/mocks/dummy-data.mock.js index efbc16e4cf85..115e04918b0b 100644 --- a/cvat-core/tests/mocks/dummy-data.mock.js +++ b/cvat-core/tests/mocks/dummy-data.mock.js @@ -143,6 +143,174 @@ const shareDummyData = [ }, ]; +const projectsDummyData = { + count: 2, + next: null, + previous: null, + results: [ + { + url: 'http://192.168.0.139:7000/api/v1/projects/6', + id: 6, + name: 'Some empty project', + labels: [], + tasks: [], + owner: { + url: 'http://localhost:7000/api/v1/users/2', + id: 2, + username: 'bsekache', + }, + assignee: { + url: 'http://localhost:7000/api/v1/users/2', + id: 2, + username: 'bsekache', + }, + bug_tracker: '', + created_date: '2020-10-19T20:41:07.808029Z', + updated_date: '2020-10-19T20:41:07.808084Z', + status: 'annotation', + }, + { + url: 'http://192.168.0.139:7000/api/v1/projects/1', + id: 2, + name: 'Test project with roads', + labels: [ + { + id: 1, + name: 'car', + color: '#2080c0', + attributes: [ + { + id: 199, + name: 'color', + mutable: false, + input_type: 'select', + default_value: 'red', + values: ['red', 'black', 'white', 'yellow', 'pink', 'green', 'blue', 'orange'], + }, + ], + }, + ], + tasks: [ + { + url: 'http://192.168.0.139:7000/api/v1/tasks/2', + id: 2, + name: 'road 1', + project_id: 1, + mode: 'interpolation', + owner: { + url: 'http://localhost:7000/api/v1/users/1', + id: 1, + username: 'admin', + }, + assignee: null, + bug_tracker: '', + created_date: '2020-10-12T08:59:59.878083Z', + updated_date: '2020-10-18T21:02:20.831294Z', + overlap: 5, + segment_size: 100, + z_order: false, + status: 'completed', + labels: [ + { + id: 1, + name: 'car', + color: '#2080c0', + attributes: [ + { + id: 199, + name: 'color', + mutable: false, + input_type: 'select', + default_value: 'red', + values: ['red', 'black', 'white', 'yellow', 'pink', 'green', 'blue', 'orange'], + }, + ], + }, + ], + segments: [ + { + start_frame: 0, + stop_frame: 99, + jobs: [ + { + url: 'http://192.168.0.139:7000/api/v1/jobs/1', + id: 1, + assignee: null, + status: 'completed', + }, + ], + }, + { + start_frame: 95, + stop_frame: 194, + jobs: [ + { + url: 'http://192.168.0.139:7000/api/v1/jobs/2', + id: 2, + assignee: null, + status: 'completed', + }, + ], + }, + { + start_frame: 190, + stop_frame: 289, + jobs: [ + { + url: 'http://192.168.0.139:7000/api/v1/jobs/3', + id: 3, + assignee: null, + status: 'completed', + }, + ], + }, + { + start_frame: 285, + stop_frame: 384, + jobs: [ + { + url: 'http://192.168.0.139:7000/api/v1/jobs/4', + id: 4, + assignee: null, + status: 'completed', + }, + ], + }, + { + start_frame: 380, + stop_frame: 431, + jobs: [ + { + url: 'http://192.168.0.139:7000/api/v1/jobs/5', + id: 5, + assignee: null, + status: 'completed', + }, + ], + }, + ], + data_chunk_size: 36, + data_compressed_chunk_type: 'imageset', + data_original_chunk_type: 'video', + size: 432, + image_quality: 100, + data: 1, + }, + ], + owner: { + url: 'http://localhost:7000/api/v1/users/1', + id: 1, + username: 'admin', + }, + assignee: null, + bug_tracker: '', + created_date: '2020-10-12T08:21:56.558898Z', + updated_date: '2020-10-12T08:21:56.558982Z', + status: 'completed', + }, + ], +}; + const tasksDummyData = { count: 5, next: null, @@ -2352,6 +2520,7 @@ const frameMetaDummyData = { module.exports = { tasksDummyData, + projectsDummyData, aboutDummyData, shareDummyData, usersDummyData, diff --git a/cvat-core/tests/mocks/server-proxy.mock.js b/cvat-core/tests/mocks/server-proxy.mock.js index 983562e4098a..f9843b1c0b33 100644 --- a/cvat-core/tests/mocks/server-proxy.mock.js +++ b/cvat-core/tests/mocks/server-proxy.mock.js @@ -4,6 +4,7 @@ const { tasksDummyData, + projectsDummyData, aboutDummyData, formatsDummyData, shareDummyData, @@ -13,6 +14,22 @@ const { frameMetaDummyData, } = require('./dummy-data.mock'); +function QueryStringToJSON(query) { + const pairs = [...new URLSearchParams(query).entries()]; + + const result = {}; + for (const pair of pairs) { + const [key, value] = pair; + if (['id'].includes(key)) { + result[key] = +value; + } else { + result[key] = value; + } + } + + return JSON.parse(JSON.stringify(result)); +} + class ServerProxy { constructor() { async function about() { @@ -55,23 +72,65 @@ class ServerProxy { return null; } - async function getTasks(filter = '') { - function QueryStringToJSON(query) { - const pairs = [...new URLSearchParams(query).entries()]; - - const result = {}; - for (const pair of pairs) { - const [key, value] = pair; - if (['id'].includes(key)) { - result[key] = +value; - } else { - result[key] = value; + async function getProjects(filter = '') { + const queries = QueryStringToJSON(filter); + const result = projectsDummyData.results.filter((x) => { + for (const key in queries) { + if (Object.prototype.hasOwnProperty.call(queries, key)) { + // TODO: Particular match for some fields is not checked + if (queries[key] !== x[key]) { + return false; + } } } - return JSON.parse(JSON.stringify(result)); + return true; + }); + + return result; + } + + async function saveProject(id, projectData) { + const object = projectsDummyData.results.filter((project) => project.id === id)[0]; + for (const prop in projectData) { + if ( + Object.prototype.hasOwnProperty.call(projectData, prop) + && Object.prototype.hasOwnProperty.call(object, prop) + ) { + object[prop] = projectData[prop]; + } + } + } + + async function createProject(projectData) { + const id = Math.max(...projectsDummyData.results.map((el) => el.id)) + 1; + projectsDummyData.results.push({ + id, + url: `http://localhost:7000/api/v1/projects/${id}`, + name: projectData.name, + owner: 1, + assignee: null, + bug_tracker: projectData.bug_tracker, + created_date: '2019-05-16T13:08:00.621747+03:00', + updated_date: '2019-05-16T13:08:00.621797+03:00', + status: 'annotation', + tasks: [], + labels: JSON.parse(JSON.stringify(projectData.labels)), + }); + + const createdProject = await getProjects(`?id=${id}`); + return createdProject[0]; + } + + async function deleteProject(id) { + const projects = projectsDummyData.results; + const project = projects.filter((el) => el.id === id)[0]; + if (project) { + projects.splice(projects.indexOf(project), 1); } + } + async function getTasks(filter = '') { // Emulation of a query filter const queries = QueryStringToJSON(filter); const result = tasksDummyData.results.filter((x) => { @@ -108,6 +167,7 @@ class ServerProxy { id, url: `http://localhost:7000/api/v1/tasks/${id}`, name: taskData.name, + project_id: taskData.project_id || null, size: 5000, mode: 'interpolation', owner: { @@ -263,6 +323,16 @@ class ServerProxy { writable: false, }, + projects: { + value: Object.freeze({ + get: getProjects, + save: saveProject, + create: createProject, + delete: deleteProject, + }), + writable: false, + }, + tasks: { value: Object.freeze({ getTasks, diff --git a/cvat-data/.eslintignore b/cvat-data/.eslintignore index dbec63908d1f..225f7134a524 100644 --- a/cvat-data/.eslintignore +++ b/cvat-data/.eslintignore @@ -1 +1,2 @@ **/3rdparty/*.js +webpack.config.js diff --git a/cvat-ui/.eslintignore b/cvat-ui/.eslintignore new file mode 100644 index 000000000000..6de001d87b3e --- /dev/null +++ b/cvat-ui/.eslintignore @@ -0,0 +1 @@ +webpack.config.js diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index f864bcacd033..44604467315e 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -2,7 +2,9 @@ // // SPDX-License-Identifier: MIT -import { AnyAction, Dispatch, ActionCreator, Store } from 'redux'; +import { + AnyAction, Dispatch, ActionCreator, Store, +} from 'redux'; import { ThunkAction } from 'utils/redux'; import { @@ -234,7 +236,9 @@ export function switchZLayer(cur: number): AnyAction { export function fetchAnnotationsAsync(): ThunkAction { return async (dispatch: ActionCreator): Promise => { try { - const { filters, frame, showAllInterpolationTracks, jobInstance } = receiveAnnotationsParameters(); + const { + filters, frame, showAllInterpolationTracks, jobInstance, + } = receiveAnnotationsParameters(); const states = await jobInstance.annotations.get(frame, showAllInterpolationTracks, filters); const [minZ, maxZ] = computeZRange(states); @@ -926,6 +930,10 @@ export function getJobAsync(tid: number, jid: number, initialFrame: number, init throw new Error(`Task ${tid} doesn't contain the job ${jid}`); } + if (!task.labels.length && task.projectId) { + throw new Error(`Project ${task.projectId} does not contain any label`); + } + const frameNumber = Math.max(Math.min(job.stopFrame, initialFrame), job.startFrame); const frameData = await job.frames.get(frameNumber); // call first getting of frame data before rendering interface @@ -1077,7 +1085,9 @@ export function splitTrack(enabled: boolean): AnyAction { export function updateAnnotationsAsync(statesToUpdate: any[]): ThunkAction { return async (dispatch: ActionCreator): Promise => { - const { jobInstance, filters, frame, showAllInterpolationTracks } = receiveAnnotationsParameters(); + const { + jobInstance, filters, frame, showAllInterpolationTracks, + } = receiveAnnotationsParameters(); try { if (statesToUpdate.some((state: any): boolean => state.updateFlags.zOrder)) { diff --git a/cvat-ui/src/actions/projects-actions.ts b/cvat-ui/src/actions/projects-actions.ts new file mode 100644 index 000000000000..2aa385db7093 --- /dev/null +++ b/cvat-ui/src/actions/projects-actions.ts @@ -0,0 +1,171 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import { Dispatch, ActionCreator } from 'redux'; + +import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; +import { ProjectsQuery, CombinedState } from 'reducers/interfaces'; +import { getTasksSuccess, updateTaskSuccess } from 'actions/tasks-actions'; +import { getCVATStore } from 'cvat-store'; +import getCore from 'cvat-core-wrapper'; + +const cvat = getCore(); + +export enum ProjectsActionTypes { + UPDATE_PROJECTS_GETTING_QUERY = 'UPDATE_PROJECTS_GETTING_QUERY', + GET_PROJECTS = 'GET_PROJECTS', + GET_PROJECTS_SUCCESS = 'GET_PROJECTS_SUCCESS', + GET_PROJECTS_FAILED = 'GET_PROJECTS_FAILED', + CREATE_PROJECT = 'CREATE_PROJECT', + CREATE_PROJECT_SUCCESS = 'CREATE_PROJECT_SUCCESS', + CREATE_PROJECT_FAILED = 'CREATE_PROJECT_FAILED', + UPDATE_PROJECT = 'UPDATE_PROJECT', + UPDATE_PROJECT_SUCCESS = 'UPDATE_PROJECT_SUCCESS', + UPDATE_PROJECT_FAILED = 'UPDATE_PROJECT_FAILED', + DELETE_PROJECT = 'DELETE_PROJECT', + DELETE_PROJECT_SUCCESS = 'DELETE_PROJECT_SUCCESS', + DELETE_PROJECT_FAILED = 'DELETE_PROJECT_FAILED', +} + +// prettier-ignore +const projectActions = { + getProjects: () => createAction(ProjectsActionTypes.GET_PROJECTS), + getProjectsSuccess: (array: any[], count: number) => ( + createAction(ProjectsActionTypes.GET_PROJECTS_SUCCESS, { array, count }) + ), + getProjectsFailed: (error: any) => createAction(ProjectsActionTypes.GET_PROJECTS_FAILED, { error }), + updateProjectsGettingQuery: (query: Partial) => ( + createAction(ProjectsActionTypes.UPDATE_PROJECTS_GETTING_QUERY, { query }) + ), + createProject: () => createAction(ProjectsActionTypes.CREATE_PROJECT), + createProjectSuccess: (projectId: number) => ( + createAction(ProjectsActionTypes.CREATE_PROJECT_SUCCESS, { projectId }) + ), + createProjectFailed: (error: any) => createAction(ProjectsActionTypes.CREATE_PROJECT_FAILED, { error }), + updateProject: () => createAction(ProjectsActionTypes.UPDATE_PROJECT), + updateProjectSuccess: (project: any) => createAction(ProjectsActionTypes.UPDATE_PROJECT_SUCCESS, { project }), + updateProjectFailed: (project: any, error: any) => ( + createAction(ProjectsActionTypes.UPDATE_PROJECT_FAILED, { project, error }) + ), + deleteProject: (projectId: number) => createAction(ProjectsActionTypes.DELETE_PROJECT, { projectId }), + deleteProjectSuccess: (projectId: number) => ( + createAction(ProjectsActionTypes.DELETE_PROJECT_SUCCESS, { projectId }) + ), + deleteProjectFailed: (projectId: number, error: any) => ( + createAction(ProjectsActionTypes.DELETE_PROJECT_FAILED, { projectId, error }) + ), +}; + +export type ProjectActions = ActionUnion; + +export function getProjectsAsync(query: Partial): ThunkAction { + return async (dispatch: ActionCreator): Promise => { + dispatch(projectActions.getProjects()); + dispatch(projectActions.updateProjectsGettingQuery(query)); + + // Clear query object from null fields + const filteredQuery: Partial = { + page: 1, + ...query, + }; + for (const key in filteredQuery) { + if (filteredQuery[key] === null || typeof filteredQuery[key] === 'undefined') { + delete filteredQuery[key]; + } + } + + let result = null; + try { + result = await cvat.projects.get(filteredQuery); + } catch (error) { + dispatch(projectActions.getProjectsFailed(error)); + return; + } + + const array = Array.from(result); + + const tasks: any[] = []; + const taskPreviewPromises: Promise[] = []; + + for (const project of array) { + taskPreviewPromises.push( + ...(project as any).tasks.map((task: any): string => { + tasks.push(task); + return (task as any).frames.preview().catch(() => ''); + }), + ); + } + + const taskPreviews = await Promise.all(taskPreviewPromises); + + dispatch(projectActions.getProjectsSuccess(array, result.count)); + + const store = getCVATStore(); + const state: CombinedState = store.getState(); + + if (!state.tasks.fetching) { + dispatch( + getTasksSuccess(tasks, taskPreviews, tasks.length, { + page: 1, + assignee: null, + id: null, + mode: null, + name: null, + owner: null, + search: null, + status: null, + }), + ); + } + }; +} + +export function createProjectAsync(data: any): ThunkAction { + return async (dispatch: ActionCreator): Promise => { + const projectInstance = new cvat.classes.Project(data); + + dispatch(projectActions.createProject()); + try { + const savedProject = await projectInstance.save(); + dispatch(projectActions.createProjectSuccess(savedProject.id)); + } catch (error) { + dispatch(projectActions.createProjectFailed(error)); + } + }; +} + +export function updateProjectAsync(projectInstance: any): ThunkAction { + return async (dispatch: ActionCreator): Promise => { + try { + dispatch(projectActions.updateProject()); + await projectInstance.save(); + const [project] = await cvat.projects.get({ id: projectInstance.id }); + dispatch(projectActions.updateProjectSuccess(project)); + project.tasks.forEach((task: any) => { + dispatch(updateTaskSuccess(task)); + }); + } catch (error) { + let project = null; + try { + [project] = await cvat.projects.get({ id: projectInstance.id }); + } catch (fetchError) { + dispatch(projectActions.updateProjectFailed(projectInstance, error)); + return; + } + dispatch(projectActions.updateProjectFailed(project, error)); + } + }; +} + +export function deleteProjectAsync(projectInstance: any): ThunkAction { + return async (dispatch: ActionCreator): Promise => { + dispatch(projectActions.deleteProject(projectInstance.id)); + try { + await projectInstance.delete(); + dispatch(projectActions.deleteProjectSuccess(projectInstance.id)); + } catch (error) { + dispatch(projectActions.deleteProjectFailed(projectInstance.id, error)); + } + }; +} diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index 1f6eebdb95e4..bf1ba2e42562 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -46,7 +46,7 @@ function getTasks(): AnyAction { return action; } -function getTasksSuccess(array: any[], previews: string[], count: number, query: TasksQuery): AnyAction { +export function getTasksSuccess(array: any[], previews: string[], count: number, query: TasksQuery): AnyAction { const action = { type: TasksActionTypes.GET_TASKS_SUCCESS, payload: { @@ -93,25 +93,11 @@ export function getTasksAsync(query: TasksQuery): ThunkAction, {}, } const array = Array.from(result); - const previews = []; - const promises = array.map((task): string => (task as any).frames.preview()); + const promises = array.map((task): string => (task as any).frames.preview().catch('')); dispatch(getInferenceStatusAsync()); - for (const promise of promises) { - try { - // a tricky moment - // await is okay in loop in this case, there aren't any performance bottleneck - // because all server requests have been already sent in parallel - - // eslint-disable-next-line no-await-in-loop - previews.push(await promise); - } catch (error) { - previews.push(''); - } - } - - dispatch(getTasksSuccess(array, previews, result.count, query)); + dispatch(getTasksSuccess(array, await Promise.all(promises), result.count, query)); }; } @@ -381,6 +367,9 @@ export function createTaskAsync(data: any): ThunkAction, {}, {}, A use_cache: data.advanced.useCache, }; + if (data.projectId) { + description.project_id = data.projectId; + } if (data.advanced.bugTracker) { description.bug_tracker = data.advanced.bugTracker; } @@ -445,7 +434,7 @@ function updateTask(): AnyAction { return action; } -function updateTaskSuccess(task: any): AnyAction { +export function updateTaskSuccess(task: any): AnyAction { const action = { type: TasksActionTypes.UPDATE_TASK_SUCCESS, payload: { task }, diff --git a/cvat-ui/src/components/create-project-page/create-project-content.tsx b/cvat-ui/src/components/create-project-page/create-project-content.tsx new file mode 100644 index 000000000000..b3f7a6a532f7 --- /dev/null +++ b/cvat-ui/src/components/create-project-page/create-project-content.tsx @@ -0,0 +1,160 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React, { + useState, useRef, useEffect, Component, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router'; +import { Col, Row } from 'antd/lib/grid'; +import Text from 'antd/lib/typography/Text'; +import Form, { FormComponentProps, WrappedFormUtils } from 'antd/lib/form/Form'; +import Button from 'antd/lib/button'; +import Input from 'antd/lib/input'; +import notification from 'antd/lib/notification'; + +import patterns from 'utils/validation-patterns'; +import { CombinedState } from 'reducers/interfaces'; +import LabelsEditor from 'components/labels-editor/labels-editor'; +import { createProjectAsync } from 'actions/projects-actions'; + +type FormRefType = Component, any, any> & WrappedFormUtils; + +const ProjectNameEditor = Form.create()( + (props: FormComponentProps): JSX.Element => { + const { form } = props; + const { getFieldDecorator } = form; + + return ( +
    e.preventDefault()}> + Name}> + {getFieldDecorator('name', { + rules: [ + { + required: true, + message: 'Please, specify a name', + }, + ], + })()} + +
    + ); + }, +); + +const AdvanvedConfigurationForm = Form.create()( + (props: FormComponentProps): JSX.Element => { + const { form } = props; + const { getFieldDecorator } = form; + + return ( +
    e.preventDefault()}> + Issue tracker} + extra='Attach issue tracker where the project is described' + hasFeedback + > + {getFieldDecorator('bug_tracker', { + rules: [ + { + validator: (_, value, callback): void => { + if (value && !patterns.validateURL.pattern.test(value)) { + callback('Issue tracker must be URL'); + } else { + callback(); + } + }, + }, + ], + })()} + +
    + ); + }, +); + +export default function CreateProjectContent(): JSX.Element { + const [projectLabels, setProjectLabels] = useState([]); + const shouldShowNotification = useRef(false); + const nameFormRef = useRef(null); + const advancedFormRef = useRef(null); + const dispatch = useDispatch(); + const history = useHistory(); + + const newProjectId = useSelector((state: CombinedState) => state.projects.activities.creates.id); + + useEffect(() => { + if (Number.isInteger(newProjectId) && shouldShowNotification.current) { + const btn = ; + + // Clear new project forms + if (nameFormRef.current) nameFormRef.current.resetFields(); + if (advancedFormRef.current) advancedFormRef.current.resetFields(); + setProjectLabels([]); + + notification.info({ + message: 'The project has been created', + btn, + }); + } + + shouldShowNotification.current = true; + }, [newProjectId]); + + const onSumbit = (): void => { + interface Project { + [key: string]: any; + } + + const projectData: Project = {}; + if (nameFormRef.current !== null) { + nameFormRef.current.validateFields((error, value) => { + if (!error) { + projectData.name = value.name; + } + }); + } + + if (advancedFormRef.current !== null) { + advancedFormRef.current.validateFields((error, values) => { + if (!error) { + for (const [field, value] of Object.entries(values)) { + projectData[field] = value; + } + } + }); + } + + projectData.labels = projectLabels; + + if (!projectData.name) return; + + dispatch(createProjectAsync(projectData)); + }; + + return ( + + + + + + Labels: + { + setProjectLabels(newLabels); + }} + /> + + + + + + + + + ); +} diff --git a/cvat-ui/src/components/create-project-page/create-project-page.tsx b/cvat-ui/src/components/create-project-page/create-project-page.tsx new file mode 100644 index 000000000000..5072edde5869 --- /dev/null +++ b/cvat-ui/src/components/create-project-page/create-project-page.tsx @@ -0,0 +1,21 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React from 'react'; +import { Row, Col } from 'antd/lib/grid'; +import Text from 'antd/lib/typography/Text'; + +import CreateProjectContent from './create-project-content'; + +export default function CreateProjectPageComponent(): JSX.Element { + return ( + + + Create a new project + + + + ); +} diff --git a/cvat-ui/src/components/create-project-page/styles.scss b/cvat-ui/src/components/create-project-page/styles.scss new file mode 100644 index 000000000000..a6fdbd4bbe06 --- /dev/null +++ b/cvat-ui/src/components/create-project-page/styles.scss @@ -0,0 +1,42 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +@import '../../base.scss'; + +.cvat-create-project-form-wrapper { + text-align: center; + padding-top: $grid-unit-size * 5; + overflow-y: auto; + height: 90%; + position: fixed; + width: 100%; + + > div > span { + font-size: $grid-unit-size * 4; + } +} + +.cvat-create-project-content { + margin-top: $grid-unit-size * 2; + width: 100%; + height: auto; + border: 1px solid $border-color-1; + border-radius: 3px; + padding: $grid-unit-size * 2; + background: $background-color-1; + text-align: initial; + + > div:not(first-child) { + margin-top: $grid-unit-size; + } + + > div:nth-child(4) { + display: flex; + justify-content: flex-end; + + > button { + width: $grid-unit-size * 15; + } + } +} diff --git a/cvat-ui/src/components/create-task-page/create-task-content.tsx b/cvat-ui/src/components/create-task-page/create-task-content.tsx index f30e79810104..2fcaa247945d 100644 --- a/cvat-ui/src/components/create-task-page/create-task-content.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-content.tsx @@ -13,12 +13,14 @@ import notification from 'antd/lib/notification'; import Text from 'antd/lib/typography/Text'; import ConnectedFileManager from 'containers/file-manager/file-manager'; +import LabelsEditor from 'components/labels-editor/labels-editor'; +import { Files } from 'components/file-manager/file-manager'; import BasicConfigurationForm, { BaseConfiguration } from './basic-configuration-form'; +import ProjectSearchField from './project-search-field'; import AdvancedConfigurationForm, { AdvancedConfiguration } from './advanced-configuration-form'; -import LabelsEditor from '../labels-editor/labels-editor'; -import { Files } from '../file-manager/file-manager'; export interface CreateTaskData { + projectId: number | null; basic: BaseConfiguration; advanced: AdvancedConfiguration; labels: any[]; @@ -29,12 +31,14 @@ interface Props { onCreate: (data: CreateTaskData) => void; status: string; taskId: number | null; + projectId: number | null; installedGit: boolean; } type State = CreateTaskData; const defaultState = { + projectId: null, basic: { name: '', }, @@ -63,6 +67,14 @@ class CreateTaskContent extends React.PureComponent { - const { labels } = this.state; - return !!labels.length; + private validateLabelsOrProject = (): boolean => { + const { projectId, labels } = this.state; + return !!labels.length || !!projectId; }; private validateFiles = (): boolean => { @@ -102,6 +114,12 @@ class CreateTaskContent extends React.PureComponent { + this.setState({ + projectId: value, + }); + }; + private handleSubmitBasicConfiguration = (values: BaseConfiguration): void => { this.setState({ basic: { ...values }, @@ -115,10 +133,10 @@ class CreateTaskContent extends React.PureComponent { - if (!this.validateLabels()) { + if (!this.validateLabelsOrProject()) { notification.error({ message: 'Could not create a task', - description: 'A task must contain at least one label', + description: 'A task must contain at least one label or belong to some project', }); return; } @@ -167,8 +185,36 @@ class CreateTaskContent extends React.PureComponent + + Project: + + + + + + ); + } + private renderLabelsBlock(): JSX.Element { - const { labels } = this.state; + const { projectId, labels } = this.state; + + if (projectId) { + return ( + <> + + Labels: + + + Project labels will be used + + + ); + } return ( @@ -231,12 +277,13 @@ class CreateTaskContent extends React.PureComponent {this.renderBasicBlock()} + {this.renderProjectBlock()} {this.renderLabelsBlock()} {this.renderFilesBlock()} {this.renderAdvancedBlock()} {loading ? : null} - + diff --git a/cvat-ui/src/components/create-task-page/create-task-page.tsx b/cvat-ui/src/components/create-task-page/create-task-page.tsx index ed18e5412c73..a78b90b1a49e 100644 --- a/cvat-ui/src/components/create-task-page/create-task-page.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-page.tsx @@ -4,6 +4,7 @@ import './styles.scss'; import React, { useEffect } from 'react'; +import { useLocation } from 'react-router'; import { Row, Col } from 'antd/lib/grid'; import Modal from 'antd/lib/modal'; import Text from 'antd/lib/typography/Text'; @@ -21,7 +22,17 @@ interface Props { } export default function CreateTaskPage(props: Props): JSX.Element { - const { error, status, taskId, onCreate, installedGit } = props; + const { + error, status, taskId, onCreate, installedGit, + } = props; + + const location = useLocation(); + + let projectId = null; + const params = new URLSearchParams(location.search); + if (params.get('projectId')?.match(/^[1-9]+[0-9]*$/)) { + projectId = +(params.get('projectId') as string); + } useEffect(() => { if (error) { @@ -61,7 +72,13 @@ export default function CreateTaskPage(props: Props): JSX.Element { Create a new task - + ); diff --git a/cvat-ui/src/components/create-task-page/project-search-field.tsx b/cvat-ui/src/components/create-task-page/project-search-field.tsx new file mode 100644 index 000000000000..4b9cd303c375 --- /dev/null +++ b/cvat-ui/src/components/create-task-page/project-search-field.tsx @@ -0,0 +1,91 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React, { useEffect, useState } from 'react'; +import Autocomplete from 'antd/lib/auto-complete'; + +import getCore from 'cvat-core-wrapper'; +import { SelectValue } from 'antd/lib/select'; + +const core = getCore(); + +type Props = { + value: number | null; + onSelect: (id: number | null) => void; +}; + +type Project = { + id: number; + name: string; +}; + +export default function ProjectSearchField(props: Props): JSX.Element { + const { value, onSelect } = props; + const [searchPhrase, setSearchPhrase] = useState(''); + + const [projects, setProjects] = useState([]); + + const handleSearch = (searchValue: string): void => { + if (searchValue) { + core.projects.searchNames(searchValue).then((result: Project[]) => { + if (result) { + setProjects(result); + } + }); + } else { + setProjects([]); + } + setSearchPhrase(searchValue); + onSelect(null); + }; + + const handleFocus = (open: boolean): void => { + if (!projects.length && open) { + core.projects.searchNames().then((result: Project[]) => { + if (result) { + setProjects(result); + } + }); + } + if (!open && !value && searchPhrase) { + setSearchPhrase(''); + } + }; + + const handleSelect = (_value: SelectValue): void => { + setSearchPhrase(projects.filter((proj) => proj.id === +_value)[0].name); + onSelect(_value ? +_value : null); + }; + + useEffect(() => { + if (value && !projects.filter((project) => project.id === value).length) { + core.projects.get({ id: value }).then((result: Project[]) => { + const [project] = result; + setProjects([...projects, { + id: project.id, + name: project.name, + }]); + setSearchPhrase(project.name); + onSelect(project.id); + }); + } + }, [value]); + + return ( + ({ + value: proj.id.toString(), + text: proj.name, + })) + } + /> + ); +} diff --git a/cvat-ui/src/components/create-task-page/styles.scss b/cvat-ui/src/components/create-task-page/styles.scss index a9b03632b13c..59e89cd94540 100644 --- a/cvat-ui/src/components/create-task-page/styles.scss +++ b/cvat-ui/src/components/create-task-page/styles.scss @@ -30,9 +30,13 @@ margin-top: 10px; } - > div:nth-child(7) > button { + .cvat-create-task-submit-section > button { float: right; width: 120px; } + + .cvat-project-search-field { + width: 100%; + } } } diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index 8b600cd5808c..b3c742cd9862 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -14,14 +14,17 @@ import Header from 'components/header/header'; import ResetPasswordPageConfirmComponent from 'components/reset-password-confirm-page/reset-password-confirm-page'; import ResetPasswordPageComponent from 'components/reset-password-page/reset-password-page'; import ShorcutsDialog from 'components/shortcuts-dialog/shortcuts-dialog'; +import ProjectsPageComponent from 'components/projects-page/projects-page'; +import CreateProjectPageComponent from 'components/create-project-page/create-project-page'; +import ProjectPageComponent from 'components/project-page/project-page'; +import TasksPageContainer from 'containers/tasks-page/tasks-page'; import LoginWithTokenComponent from 'components/login-with-token/login-with-token'; -import AnnotationPageContainer from 'containers/annotation-page/annotation-page'; import CreateTaskPageContainer from 'containers/create-task-page/create-task-page'; -import LoginPageContainer from 'containers/login-page/login-page'; +import TaskPageContainer from 'containers/task-page/task-page'; import ModelsPageContainer from 'containers/models-page/models-page'; +import AnnotationPageContainer from 'containers/annotation-page/annotation-page'; +import LoginPageContainer from 'containers/login-page/login-page'; import RegisterPageContainer from 'containers/register-page/register-page'; -import TaskPageContainer from 'containers/task-page/task-page'; -import TasksPageContainer from 'containers/tasks-page/tasks-page'; import getCore from 'cvat-core-wrapper'; import React from 'react'; import { configure, ExtendedKeyMapOptions, GlobalHotKeys } from 'react-hotkeys'; @@ -297,6 +300,9 @@ class CVATApplication extends React.PureComponent + + + diff --git a/cvat-ui/src/components/header/header.tsx b/cvat-ui/src/components/header/header.tsx index 41ab7007dd47..9527cbd6fb78 100644 --- a/cvat-ui/src/components/header/header.tsx +++ b/cvat-ui/src/components/header/header.tsx @@ -137,7 +137,9 @@ function HeaderContainer(props: Props): JSX.Element { isModelsPluginActive, } = props; - const { CHANGELOG_URL, LICENSE_URL, GITTER_URL, FORUM_URL, GITHUB_URL } = consts; + const { + CHANGELOG_URL, LICENSE_URL, GITTER_URL, FORUM_URL, GITHUB_URL, + } = consts; const history = useHistory(); @@ -238,6 +240,18 @@ function HeaderContainer(props: Props): JSX.Element {
    + + + + {tasks + .filter((task) => task.instance.projectId === project.id) + .map((task: Task) => ( +
    diff --git a/cvat-ui/src/components/tasks-page/styles.scss b/cvat-ui/src/components/tasks-page/styles.scss index 33fd12a59a05..73ad1e575b4b 100644 --- a/cvat-ui/src/components/tasks-page/styles.scss +++ b/cvat-ui/src/components/tasks-page/styles.scss @@ -11,26 +11,16 @@ height: 100%; width: 100%; - > div:nth-child(1) { - padding-bottom: 10px; - - div > { - span { - color: $text-color; - } - } - } - - > div:nth-child(3) { + > div:nth-child(2) { height: 83%; padding-top: 10px; } - > div:nth-child(4) { + > div:nth-child(3) { padding-top: 10px; } - > div:nth-child(2) { + > div:nth-child(1) { > div:nth-child(1) { display: flex; diff --git a/cvat-ui/src/components/tasks-page/top-bar.tsx b/cvat-ui/src/components/tasks-page/top-bar.tsx index 28b7780b9a5a..8e8483d859cd 100644 --- a/cvat-ui/src/components/tasks-page/top-bar.tsx +++ b/cvat-ui/src/components/tasks-page/top-bar.tsx @@ -3,8 +3,7 @@ // SPDX-License-Identifier: MIT import React from 'react'; -import { RouteComponentProps } from 'react-router'; -import { withRouter } from 'react-router-dom'; +import { useHistory } from 'react-router'; import { Row, Col } from 'antd/lib/grid'; import Button from 'antd/lib/button'; import Input from 'antd/lib/input'; @@ -15,16 +14,13 @@ interface VisibleTopBarProps { searchValue: string; } -function TopBarComponent(props: VisibleTopBarProps & RouteComponentProps): JSX.Element { - const { searchValue, history, onSearch } = props; +export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element { + const { searchValue, onSearch } = props; + + const history = useHistory(); return ( <> - - - Default project - - Tasks @@ -45,5 +41,3 @@ function TopBarComponent(props: VisibleTopBarProps & RouteComponentProps): JSX.E ); } - -export default withRouter(TopBarComponent); diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index 8c283a7a36f2..5a4802c6bcf0 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -21,6 +21,35 @@ export interface AuthState { allowResetPassword: boolean; } +export interface ProjectsQuery { + page: number; + id: number | null; + search: string | null; + owner: string | null; + name: string | null; + status: string | null; + [key: string]: string | number | null | undefined; +} + +export type Project = any; + +export interface ProjectsState { + initialized: boolean; + fetching: boolean; + count: number; + current: Project[]; + gettingQuery: ProjectsQuery; + activities: { + creates: { + id: null | number; + error: string; + }; + deletes: { + [projectId: number]: boolean; // deleted (deleting if in dictionary) + }; + }; +} + export interface TasksQuery { page: number; id: number | null; @@ -192,6 +221,12 @@ export interface NotificationsState { resetPassword: null | ErrorState; loadAuthActions: null | ErrorState; }; + projects: { + fetching: null | ErrorState; + updating: null | ErrorState; + deleting: null | ErrorState; + creating: null | ErrorState; + }; tasks: { fetching: null | ErrorState; updating: null | ErrorState; @@ -487,6 +522,7 @@ export interface MetaState { export interface CombinedState { auth: AuthState; + projects: ProjectsState; tasks: TasksState; about: AboutState; share: ShareState; diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index 862dd674476b..3a34a4140d48 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -9,6 +9,7 @@ import { FormatsActionTypes } from 'actions/formats-actions'; import { ModelsActionTypes } from 'actions/models-actions'; import { ShareActionTypes } from 'actions/share-actions'; import { TasksActionTypes } from 'actions/tasks-actions'; +import { ProjectsActionTypes } from 'actions/projects-actions'; import { AboutActionTypes } from 'actions/about-actions'; import { AnnotationActionTypes } from 'actions/annotation-actions'; import { NotificationsActionType } from 'actions/notification-actions'; @@ -29,6 +30,12 @@ const defaultState: NotificationsState = { resetPassword: null, loadAuthActions: null, }, + projects: { + fetching: null, + updating: null, + deleting: null, + creating: null, + }, tasks: { fetching: null, updating: null, @@ -414,6 +421,72 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case ProjectsActionTypes.GET_PROJECTS_FAILED: { + return { + ...state, + errors: { + ...state.errors, + projects: { + ...state.errors.projects, + fetching: { + message: 'Could not fetch projects', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case ProjectsActionTypes.CREATE_PROJECT_FAILED: { + return { + ...state, + errors: { + ...state.errors, + projects: { + ...state.errors.projects, + creating: { + message: 'Could not create the project', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case ProjectsActionTypes.UPDATE_PROJECT_FAILED: { + const { id: projectId } = action.payload.project; + return { + ...state, + errors: { + ...state.errors, + projects: { + ...state.errors.projects, + updating: { + message: + 'Could not update ' + + `project ${projectId}`, + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case ProjectsActionTypes.DELETE_PROJECT_FAILED: { + const { projectId } = action.payload; + return { + ...state, + errors: { + ...state.errors, + projects: { + ...state.errors.projects, + updating: { + message: + 'Could not delete ' + + `project ${projectId}`, + reason: action.payload.error.toString(), + }, + }, + }, + }; + } case FormatsActionTypes.GET_FORMATS_FAILED: { return { ...state, diff --git a/cvat-ui/src/reducers/projects-reducer.ts b/cvat-ui/src/reducers/projects-reducer.ts new file mode 100644 index 000000000000..d6a7c1eee491 --- /dev/null +++ b/cvat-ui/src/reducers/projects-reducer.ts @@ -0,0 +1,192 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import { AnyAction } from 'redux'; +import { ProjectsActionTypes } from 'actions/projects-actions'; +import { BoundariesActionTypes } from 'actions/boundaries-actions'; +import { AuthActionTypes } from 'actions/auth-actions'; + +import { Project, ProjectsState } from './interfaces'; + +const defaultState: ProjectsState = { + initialized: false, + fetching: false, + count: 0, + current: [], + gettingQuery: { + page: 1, + id: null, + search: null, + owner: null, + name: null, + status: null, + }, + activities: { + deletes: {}, + creates: { + id: null, + error: '', + }, + }, +}; + +export default (state: ProjectsState = defaultState, action: AnyAction): ProjectsState => { + switch (action.type) { + case ProjectsActionTypes.UPDATE_PROJECTS_GETTING_QUERY: + return { + ...state, + gettingQuery: { + ...defaultState.gettingQuery, + ...action.payload.query, + }, + }; + case ProjectsActionTypes.GET_PROJECTS: + return { + ...state, + initialized: false, + fetching: true, + count: 0, + current: [], + }; + case ProjectsActionTypes.GET_PROJECTS_SUCCESS: { + return { + ...state, + initialized: true, + fetching: false, + count: action.payload.count, + current: action.payload.array, + }; + } + case ProjectsActionTypes.GET_PROJECTS_FAILED: { + return { + ...state, + initialized: true, + fetching: false, + }; + } + case ProjectsActionTypes.CREATE_PROJECT: { + return { + ...state, + activities: { + ...state.activities, + creates: { + id: null, + error: '', + }, + }, + }; + } + case ProjectsActionTypes.CREATE_PROJECT_FAILED: { + return { + ...state, + activities: { + ...state.activities, + creates: { + ...state.activities.creates, + error: action.payload.error.toString(), + }, + }, + }; + } + case ProjectsActionTypes.CREATE_PROJECT_SUCCESS: { + return { + ...state, + activities: { + ...state.activities, + creates: { + id: action.payload.projectId, + error: '', + }, + }, + }; + } + case ProjectsActionTypes.UPDATE_PROJECT: { + return { + ...state, + }; + } + case ProjectsActionTypes.UPDATE_PROJECT_SUCCESS: { + return { + ...state, + current: state.current.map( + (project): Project => { + if (project.id === action.payload.project.id) { + return action.payload.project; + } + + return project; + }, + ), + }; + } + case ProjectsActionTypes.UPDATE_PROJECT_FAILED: { + return { + ...state, + current: state.current.map( + (project): Project => { + if (project.id === action.payload.project.id) { + return action.payload.project; + } + + return project; + }, + ), + }; + } + case ProjectsActionTypes.DELETE_PROJECT: { + const { projectId } = action.payload; + const { deletes } = state.activities; + + deletes[projectId] = false; + + return { + ...state, + activities: { + ...state.activities, + deletes: { + ...deletes, + }, + }, + }; + } + case ProjectsActionTypes.DELETE_PROJECT_SUCCESS: { + const { projectId } = action.payload; + const { deletes } = state.activities; + + deletes[projectId] = true; + + return { + ...state, + activities: { + ...state.activities, + deletes: { + ...deletes, + }, + }, + }; + } + case ProjectsActionTypes.DELETE_PROJECT_FAILED: { + const { projectId } = action.payload; + const { deletes } = state.activities; + + delete deletes[projectId]; + + return { + ...state, + activities: { + ...state.activities, + deletes: { + ...deletes, + }, + }, + }; + } + case BoundariesActionTypes.RESET_AFTER_ERROR: + case AuthActionTypes.LOGOUT_SUCCESS: { + return { ...defaultState }; + } + default: + return state; + } +}; diff --git a/cvat-ui/src/reducers/root-reducer.ts b/cvat-ui/src/reducers/root-reducer.ts index 7d96841f0c16..9d73e0fd529e 100644 --- a/cvat-ui/src/reducers/root-reducer.ts +++ b/cvat-ui/src/reducers/root-reducer.ts @@ -4,6 +4,7 @@ import { combineReducers, Reducer } from 'redux'; import authReducer from './auth-reducer'; +import projectsReducer from './projects-reducer'; import tasksReducer from './tasks-reducer'; import aboutReducer from './about-reducer'; import shareReducer from './share-reducer'; @@ -19,6 +20,7 @@ import userAgreementsReducer from './useragreements-reducer'; export default function createRootReducer(): Reducer { return combineReducers({ auth: authReducer, + projects: projectsReducer, tasks: tasksReducer, about: aboutReducer, share: shareReducer, diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py index 6813c8bebdf4..75da45c762a1 100644 --- a/cvat/apps/dataset_manager/bindings.py +++ b/cvat/apps/dataset_manager/bindings.py @@ -42,7 +42,7 @@ def __init__(self, annotation_ir, db_task, host='', create_callback=None): self._frame_mapping = {} self._frame_step = db_task.data.get_frame_step() - db_labels = self._db_task.label_set.all().prefetch_related( + db_labels = (self._db_task.project if self._db_task.project_id else self._db_task).label_set.all().prefetch_related( 'attributespec_set').order_by('pk') self._label_mapping = OrderedDict( diff --git a/cvat/apps/dataset_manager/task.py b/cvat/apps/dataset_manager/task.py index 246d2ef02c1d..039548d3114b 100644 --- a/cvat/apps/dataset_manager/task.py +++ b/cvat/apps/dataset_manager/task.py @@ -93,7 +93,8 @@ def __init__(self, pk): self.ir_data = AnnotationIR() self.db_labels = {db_label.id:db_label - for db_label in db_segment.task.label_set.all()} + for db_label in (db_segment.task.project.label_set.all() + if db_segment.task.project_id else db_segment.task.label_set.all())} self.db_attributes = {} for db_label in self.db_labels.values(): diff --git a/cvat/apps/engine/admin.py b/cvat/apps/engine/admin.py index 7336db03949b..ddacf69ab027 100644 --- a/cvat/apps/engine/admin.py +++ b/cvat/apps/engine/admin.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: MIT from django.contrib import admin -from .models import Task, Segment, Job, Label, AttributeSpec +from .models import Task, Segment, Job, Label, AttributeSpec, Project class JobInline(admin.TabularInline): model = Job @@ -54,6 +54,20 @@ def has_module_permission(self, request): JobInline ] +class ProjectAdmin(admin.ModelAdmin): + date_hierarchy = 'updated_date' + readonly_fields = ('created_date', 'updated_date', 'status') + fields = ('name', 'owner', 'created_date', 'updated_date', 'status') + search_fields = ('name', 'owner__username', 'owner__first_name', + 'owner__last_name', 'owner__email', 'assignee__username', 'assignee__first_name', + 'assignee__last_name') + inlines = [ + LabelInline + ] + + def has_add_permission(self, _request): + return False + class TaskAdmin(admin.ModelAdmin): date_hierarchy = 'updated_date' readonly_fields = ('created_date', 'updated_date', 'overlap') @@ -74,3 +88,4 @@ def has_add_permission(self, request): admin.site.register(Task, TaskAdmin) admin.site.register(Segment, SegmentAdmin) admin.site.register(Label, LabelAdmin) +admin.site.register(Project, ProjectAdmin) diff --git a/cvat/apps/engine/log.py b/cvat/apps/engine/log.py index d54e161a73cd..d8804e26cd46 100644 --- a/cvat/apps/engine/log.py +++ b/cvat/apps/engine/log.py @@ -2,10 +2,15 @@ # # SPDX-License-Identifier: MIT -import os import logging from cvat.settings.base import LOGGING -from .models import Job, Task +from .models import Job, Task, Project + +def _get_project(pid): + try: + return Project.objects.get(pk=pid) + except Exception: + raise Exception('{} key must be a project identifier'.format(pid)) def _get_task(tid): try: @@ -19,6 +24,28 @@ def _get_job(jid): except Exception: raise Exception('{} key must be a job identifier'.format(jid)) +class ProjectLoggerStorage: + def __init__(self): + self._storage = dict() + + def __getitem__(self, pid): + """Get ceratain storage object for some project.""" + if pid not in self._storage: + self._storage[pid] = self._create_project_logger(pid) + return self._storage[pid] + + def _create_project_logger(self, pid): + project = _get_project(pid) + + logger = logging.getLogger('cvat.server.project_{}'.format(pid)) + server_file = logging.FileHandler(filename=project.get_log_path()) + formatter = logging.Formatter(LOGGING['formatters']['standard']['format']) + server_file.setFormatter(formatter) + logger.addHandler(server_file) + + return logger + + class TaskLoggerStorage: def __init__(self): self._storage = dict() @@ -52,6 +79,24 @@ def _get_task_logger(self, jid): job = _get_job(jid) return slogger.task[job.segment.task.id] +class ProjectClientLoggerStorage: + def __init__(self): + self._storage = dict() + + def __getitem__(self, pid): + """Get logger for exact task by id.""" + if pid not in self._storage: + self._storage[pid] = self._create_client_logger(pid) + return self._storage[pid] + + def _create_client_logger(self, pid): + project = _get_project(pid) + logger = logging.getLogger('cvat.client.project_{}'.format(pid)) + client_file = logging.FileHandler(filename=project.get_client_log_path()) + logger.addHandler(client_file) + + return logger + class TaskClientLoggerStorage: def __init__(self): self._storage = dict() @@ -89,12 +134,14 @@ class dotdict(dict): __delattr__ = dict.__delitem__ clogger = dotdict({ + 'project': ProjectClientLoggerStorage(), 'task': TaskClientLoggerStorage(), 'job': JobClientLoggerStorage(), 'glob': logging.getLogger('cvat.client'), }) slogger = dotdict({ + 'project': ProjectLoggerStorage(), 'task': TaskLoggerStorage(), 'job': JobLoggerStorage(), 'glob': logging.getLogger('cvat.server'), diff --git a/cvat/apps/engine/migrations/0033_projects_adjastment.py b/cvat/apps/engine/migrations/0033_projects_adjastment.py new file mode 100644 index 000000000000..e57bd0e6c568 --- /dev/null +++ b/cvat/apps/engine/migrations/0033_projects_adjastment.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.1 on 2020-09-24 12:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0032_remove_task_z_order'), + ] + + operations = [ + migrations.AddField( + model_name='label', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='engine.project'), + ), + migrations.AlterField( + model_name='label', + name='task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='engine.task'), + ), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index d99181bcc97f..623b9d2edbb0 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -151,10 +151,25 @@ class Project(models.Model): status = models.CharField(max_length=32, choices=StatusChoice.choices(), default=StatusChoice.ANNOTATION) + def get_project_dirname(self): + return os.path.join(settings.PROJECTS_ROOT, str(self.id)) + + def get_project_logs_dirname(self): + return os.path.join(self.get_project_dirname(), 'logs') + + def get_client_log_path(self): + return os.path.join(self.get_project_logs_dirname(), "client.log") + + def get_log_path(self): + return os.path.join(self.get_project_logs_dirname(), "project.log") + # Extend default permission model class Meta: default_permissions = () + def __str__(self): + return self.name + class Task(models.Model): project = models.ForeignKey(Project, on_delete=models.CASCADE, null=True, blank=True, related_name="tasks", @@ -255,7 +270,8 @@ class Meta: default_permissions = () class Label(models.Model): - task = models.ForeignKey(Task, on_delete=models.CASCADE) + task = models.ForeignKey(Task, null=True, blank=True, on_delete=models.CASCADE) + project = models.ForeignKey(Project, null=True, blank=True, on_delete=models.CASCADE) name = SafeCharField(max_length=64) color = models.CharField(default='', max_length=8) @@ -311,7 +327,7 @@ class ShapeType(str, Enum): POLYGON = 'polygon' # (x0, y0, ..., xn, yn) POLYLINE = 'polyline' # (x0, y0, ..., xn, yn) POINTS = 'points' # (x0, y0, ..., xn, yn) - CUBOID = 'cuboid' + CUBOID = 'cuboid' # (x0, y0, ..., x7, y7) @classmethod def choices(cls): diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index a6b8dee74c6f..7fe7c853c690 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -74,6 +74,47 @@ class Meta: model = models.Label fields = ('id', 'name', 'color', 'attributes') + @staticmethod + def update_instance(validated_data, parent_instance): + attributes = validated_data.pop('attributespec_set', []) + instance = dict() + if isinstance(parent_instance, models.Project): + instance['project'] = parent_instance + logger = slogger.project[parent_instance.id] + else: + instance['task'] = parent_instance + logger = slogger.task[parent_instance.id] + (db_label, created) = models.Label.objects.get_or_create(name=validated_data['name'], + **instance) + if created: + logger.info("New {} label was created".format(db_label.name)) + else: + logger.info("{} label was updated".format(db_label.name)) + if not validated_data.get('color', None): + label_names = [l.name for l in + instance[tuple(instance.keys())[0]].label_set.exclude(id=db_label.id).order_by('id') + ] + db_label.color = get_label_color(db_label.name, label_names) + else: + db_label.color = validated_data.get('color', db_label.color) + db_label.save() + for attr in attributes: + (db_attr, created) = models.AttributeSpec.objects.get_or_create( + label=db_label, name=attr['name'], defaults=attr) + if created: + logger.info("New {} attribute for {} label was created" + .format(db_attr.name, db_label.name)) + else: + logger.info("{} attribute for {} label was updated" + .format(db_attr.name, db_label.name)) + + # FIXME: need to update only "safe" fields + db_attr.default_value = attr.get('default_value', db_attr.default_value) + db_attr.mutable = attr.get('mutable', db_attr.mutable) + db_attr.input_type = attr.get('input_type', db_attr.input_type) + db_attr.values = attr.get('values', db_attr.values) + db_attr.save() + class JobCommitSerializer(serializers.ModelSerializer): class Meta: model = models.JobCommit @@ -155,6 +196,7 @@ class RqStatusSerializer(serializers.Serializer): message = serializers.CharField(allow_blank=True, default="") class WriteOnceMixin: + """Adds support for write once fields to serializers. To use it, specify a list of fields as `write_once_fields` on the @@ -266,7 +308,7 @@ def create(self, validated_data): return db_data class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): - labels = LabelSerializer(many=True, source='label_set', partial=True) + labels = LabelSerializer(many=True, source='label_set', partial=True, required=False) segments = SegmentSerializer(many=True, source='segment_set', read_only=True) data_chunk_size = serializers.ReadOnlyField(source='data.chunk_size') data_compressed_chunk_type = serializers.ReadOnlyField(source='data.compressed_chunk_type') @@ -278,21 +320,27 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer): owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) assignee = BasicUserSerializer(allow_null=True, required=False) assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) + project_id = serializers.IntegerField(required=False) class Meta: model = models.Task - fields = ('url', 'id', 'name', 'mode', 'owner', 'assignee', 'owner_id', 'assignee_id', + fields = ('url', 'id', 'name', 'project_id', 'mode', 'owner', 'assignee', 'owner_id', 'assignee_id', 'bug_tracker', 'created_date', 'updated_date', 'overlap', 'segment_size', 'status', 'labels', 'segments', - 'project', 'data_chunk_size', 'data_compressed_chunk_type', 'data_original_chunk_type', 'size', 'image_quality', 'data') + 'data_chunk_size', 'data_compressed_chunk_type', 'data_original_chunk_type', 'size', 'image_quality', 'data') read_only_fields = ('mode', 'created_date', 'updated_date', 'status', 'data_chunk_size', 'owner', 'asignee', 'data_compressed_chunk_type', 'data_original_chunk_type', 'size', 'image_quality', 'data') - write_once_fields = ('overlap', 'segment_size') + write_once_fields = ('overlap', 'segment_size', 'project_id') ordering = ['-id'] # pylint: disable=no-self-use def create(self, validated_data): - labels = validated_data.pop('label_set') + if not (validated_data.get("label_set") or validated_data.get("project_id")): + raise serializers.ValidationError('Label set or project_id must be present') + if validated_data.get("label_set") and validated_data.get("project_id"): + raise serializers.ValidationError('Project must have only one of Label set or project_id') + + labels = validated_data.pop('label_set', []) db_task = models.Task.objects.create(**validated_data) label_names = list() for label in labels: @@ -314,6 +362,12 @@ def create(self, validated_data): db_task.save() return db_task + def to_representation(self, instance): + response = super().to_representation(instance) + if instance.project_id: + response["labels"] = LabelSerializer(many=True).to_representation(instance.project.label_set) + return response + # pylint: disable=no-self-use def update(self, instance, validated_data): instance.name = validated_data.get('name', instance.name) @@ -321,63 +375,84 @@ def update(self, instance, validated_data): instance.assignee_id = validated_data.get('assignee_id', instance.assignee_id) instance.bug_tracker = validated_data.get('bug_tracker', instance.bug_tracker) - instance.project = validated_data.get('project', instance.project) labels = validated_data.get('label_set', []) for label in labels: - attributes = label.pop('attributespec_set', []) - (db_label, created) = models.Label.objects.get_or_create(task=instance, - name=label['name']) - if created: - slogger.task[instance.id].info("New {} label was created" - .format(db_label.name)) - else: - slogger.task[instance.id].info("{} label was updated" - .format(db_label.name)) - if not label.get('color', None): - label_names = [l.name for l in - instance.label_set.all().exclude(id=db_label.id).order_by('id') - ] - db_label.color = get_label_color(db_label.name, label_names) - else: - db_label.color = label.get('color', db_label.color) - db_label.save() - for attr in attributes: - (db_attr, created) = models.AttributeSpec.objects.get_or_create( - label=db_label, name=attr['name'], defaults=attr) - if created: - slogger.task[instance.id].info("New {} attribute for {} label was created" - .format(db_attr.name, db_label.name)) - else: - slogger.task[instance.id].info("{} attribute for {} label was updated" - .format(db_attr.name, db_label.name)) - - # FIXME: need to update only "safe" fields - db_attr.default_value = attr.get('default_value', db_attr.default_value) - db_attr.mutable = attr.get('mutable', db_attr.mutable) - db_attr.input_type = attr.get('input_type', db_attr.input_type) - db_attr.values = attr.get('values', db_attr.values) - db_attr.save() + LabelSerializer.update_instance(label, instance) instance.save() return instance def validate_labels(self, value): - if not value: - raise serializers.ValidationError('Label set must not be empty') label_names = [label['name'] for label in value] if len(label_names) != len(set(label_names)): raise serializers.ValidationError('All label names must be unique for the task') return value +class ProjectSearchSerializer(serializers.ModelSerializer): + class Meta: + model = models.Project + fields = ('id', 'name') + read_only_fields = ('name',) + ordering = ['-id'] + class ProjectSerializer(serializers.ModelSerializer): + labels = LabelSerializer(many=True, source='label_set', partial=True, default=[]) + tasks = TaskSerializer(many=True, read_only=True) + owner = BasicUserSerializer(required=False) + owner_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) + assignee = BasicUserSerializer(allow_null=True, required=False) + assignee_id = serializers.IntegerField(write_only=True, allow_null=True, required=False) class Meta: model = models.Project - fields = ('url', 'id', 'name', 'owner', 'assignee', 'bug_tracker', - 'created_date', 'updated_date', 'status') - read_only_fields = ('created_date', 'updated_date', 'status') + fields = ('url', 'id', 'name', 'labels', 'tasks', 'owner', 'assignee', 'owner_id', 'assignee_id', + 'bug_tracker', 'created_date', 'updated_date', 'status') + read_only_fields = ('created_date', 'updated_date', 'status', 'owner', 'asignee') ordering = ['-id'] + # pylint: disable=no-self-use + def create(self, validated_data): + labels = validated_data.pop('label_set') + db_project = models.Project.objects.create(**validated_data) + label_names = list() + for label in labels: + attributes = label.pop('attributespec_set') + if not label.get('color', None): + label['color'] = get_label_color(label['name'], label_names) + label_names.append(label['name']) + db_label = models.Label.objects.create(project=db_project, **label) + for attr in attributes: + models.AttributeSpec.objects.create(label=db_label, **attr) + + project_path = db_project.get_project_dirname() + if os.path.isdir(project_path): + shutil.rmtree(project_path) + os.makedirs(db_project.get_project_logs_dirname()) + + db_project.save() + return db_project + + # pylint: disable=no-self-use + def update(self, instance, validated_data): + instance.name = validated_data.get('name', instance.name) + instance.owner_id = validated_data.get('owner_id', instance.owner_id) + instance.assignee_id = validated_data.get('assignee_id', instance.assignee_id) + instance.bug_tracker = validated_data.get('bug_tracker', instance.bug_tracker) + labels = validated_data.get('label_set', []) + for label in labels: + LabelSerializer.update_instance(label, instance) + + instance.save() + return instance + + + def validate_labels(self, value): + if value: + label_names = [label['name'] for label in value] + if len(label_names) != len(set(label_names)): + raise serializers.ValidationError('All label names must be unique for the project') + return value + class ExceptionSerializer(serializers.Serializer): system = serializers.CharField(max_length=255) client = serializers.CharField(max_length=255) diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py index 02635a70c815..b3b4058e00a4 100644 --- a/cvat/apps/engine/tests/test_rest_api.py +++ b/cvat/apps/engine/tests/test_rest_api.py @@ -29,7 +29,7 @@ from rest_framework.test import APIClient, APITestCase from cvat.apps.engine.models import (AttributeType, Data, Job, Project, - Segment, StatusChoice, Task, StorageMethodChoice) + Segment, StatusChoice, Task, Label, StorageMethodChoice) from cvat.apps.engine.prepare import prepare_meta, prepare_meta_for_upload def create_db_users(cls): @@ -754,10 +754,13 @@ def _check_response(self, response, db_project): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["name"], db_project.name) owner = db_project.owner.id if db_project.owner else None - self.assertEqual(response.data["owner"], owner) + response_owner = response.data["owner"]["id"] if response.data["owner"] else None + self.assertEqual(response_owner, owner) assignee = db_project.assignee.id if db_project.assignee else None - self.assertEqual(response.data["assignee"], assignee) + response_assignee = response.data["assignee"]["id"] if response.data["assignee"] else None + self.assertEqual(response_assignee, assignee) self.assertEqual(response.data["status"], db_project.status) + self.assertEqual(response.data["bug_tracker"], db_project.bug_tracker) def _check_api_v1_projects_id(self, user): for db_project in self.projects: @@ -835,10 +838,15 @@ def _run_api_v1_projects(self, user, data): def _check_response(self, response, user, data): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data["name"], data["name"]) - self.assertEqual(response.data["owner"], data.get("owner", user.id)) - self.assertEqual(response.data["assignee"], data.get("assignee")) + self.assertEqual(response.data["owner"]["id"], data.get("owner_id", user.id)) + response_assignee = response.data["assignee"]["id"] if response.data["assignee"] else None + self.assertEqual(response_assignee, data.get('assignee_id', None)) self.assertEqual(response.data["bug_tracker"], data.get("bug_tracker", "")) self.assertEqual(response.data["status"], StatusChoice.ANNOTATION) + self.assertListEqual( + [label["name"] for label in data.get("labels", [])], + [label["name"] for label in response.data["labels"]] + ) def _check_api_v1_projects(self, user, data): response = self._run_api_v1_projects(user, data) @@ -857,18 +865,26 @@ def test_api_v1_projects_admin(self): self._check_api_v1_projects(self.admin, data) data = { - "owner": self.owner.id, - "assignee": self.assignee.id, + "owner_id": self.owner.id, + "assignee_id": self.assignee.id, "name": "new name for the project" } self._check_api_v1_projects(self.admin, data) data = { - "owner": self.admin.id, + "owner_id": self.admin.id, "name": "2" } self._check_api_v1_projects(self.admin, data) + data = { + "name": "Project with labels", + "labels": [{ + "name": "car", + }] + } + self._check_api_v1_projects(self.admin, data) + def test_api_v1_projects_user(self): data = { @@ -878,8 +894,8 @@ def test_api_v1_projects_user(self): self._check_api_v1_projects(self.user, data) data = { - "owner": self.owner.id, - "assignee": self.assignee.id, + "owner_id": self.owner.id, + "assignee_id": self.assignee.id, "name": "My import project with data" } self._check_api_v1_projects(self.user, data) @@ -888,15 +904,15 @@ def test_api_v1_projects_user(self): def test_api_v1_projects_observer(self): data = { "name": "My Project #1", - "owner": self.owner.id, - "assignee": self.assignee.id + "owner_id": self.owner.id, + "assignee_id": self.assignee.id } self._check_api_v1_projects(self.observer, data) def test_api_v1_projects_no_auth(self): data = { "name": "My Project #2", - "owner": self.admin.id, + "owner_id": self.admin.id, } self._check_api_v1_projects(None, data) @@ -918,15 +934,16 @@ def _run_api_v1_projects_id(self, pid, user, data): def _check_response(self, response, db_project, data): self.assertEqual(response.status_code, status.HTTP_200_OK) - name = data.get("name", db_project.name) + name = data.get("name", data.get("name", db_project.name)) self.assertEqual(response.data["name"], name) - owner = db_project.owner.id if db_project.owner else None - owner = data.get("owner", owner) - self.assertEqual(response.data["owner"], owner) - assignee = db_project.assignee.id if db_project.assignee else None - assignee = data.get("assignee", assignee) - self.assertEqual(response.data["assignee"], assignee) - self.assertEqual(response.data["status"], db_project.status) + response_owner = response.data["owner"]["id"] if response.data["owner"] else None + db_owner = db_project.owner.id if db_project.owner else None + self.assertEqual(response_owner, data.get("owner_id", db_owner)) + response_assignee = response.data["assignee"]["id"] if response.data["assignee"] else None + db_assignee = db_project.assignee.id if db_project.assignee else None + self.assertEqual(response_assignee, data.get("assignee_id", db_assignee)) + self.assertEqual(response.data["status"], data.get("status", db_project.status)) + self.assertEqual(response.data["bug_tracker"], data.get("bug_tracker", db_project.bug_tracker)) def _check_api_v1_projects_id(self, user, data): for db_project in self.projects: @@ -941,14 +958,15 @@ def _check_api_v1_projects_id(self, user, data): def test_api_v1_projects_id_admin(self): data = { "name": "new name for the project", - "owner": self.owner.id, + "owner_id": self.owner.id, + "bug_tracker": "https://new.bug.tracker", } self._check_api_v1_projects_id(self.admin, data) def test_api_v1_projects_id_user(self): data = { "name": "new name for the project", - "owner": self.assignee.id, + "owner_id": self.assignee.id, } self._check_api_v1_projects_id(self.user, data) @@ -1328,6 +1346,16 @@ def test_api_v1_tasks_id_no_auth(self): class TaskCreateAPITestCase(APITestCase): def setUp(self): self.client = APIClient() + project = { + "name": "Project for task creation", + "owner": self.user, + } + self.project = Project.objects.create(**project) + label = { + "name": "car", + "project": self.project + } + Label.objects.create(**label) @classmethod def setUpTestData(cls): @@ -1343,6 +1371,7 @@ def _check_response(self, response, user, data): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data["name"], data["name"]) self.assertEqual(response.data["mode"], "") + self.assertEqual(response.data["project_id"], data.get("project_id", None)) self.assertEqual(response.data["owner"]["id"], data.get("owner_id", user.id)) assignee = response.data["assignee"]["id"] if response.data["assignee"] else None self.assertEqual(assignee, data.get("assignee_id", None)) @@ -1396,6 +1425,17 @@ def test_api_v1_tasks_user(self): } self._check_api_v1_tasks(self.user, data) + def test_api_vi_tasks_user_project(self): + data = { + "name": "new name for the task", + "project_id": self.project.id, + } + response = self._run_api_v1_tasks(self.user, data) + data["labels"] = [{ + "name": "car" + }] + self._check_response(response, self.user, data) + def test_api_v1_tasks_observer(self): data = { "name": "new name for the task", diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 4dda933135f9..e8b14daf01eb 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -36,13 +36,14 @@ from cvat.apps.authentication import auth from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer from cvat.apps.engine.frame_provider import FrameProvider -from cvat.apps.engine.models import Job, StatusChoice, Task, StorageMethodChoice +from cvat.apps.engine.models import Job, StatusChoice, Task, Project, StorageMethodChoice from cvat.apps.engine.serializers import ( AboutSerializer, AnnotationFileSerializer, BasicUserSerializer, DataMetaSerializer, DataSerializer, ExceptionSerializer, FileInfoSerializer, JobSerializer, LabeledDataSerializer, - LogEventSerializer, ProjectSerializer, RqStatusSerializer, - TaskSerializer, UserSerializer, PluginsSerializer, + LogEventSerializer, ProjectSerializer, ProjectSearchSerializer, + RqStatusSerializer, TaskSerializer, UserSerializer, + PluginsSerializer, ) from cvat.apps.engine.utils import av_scan_paths @@ -192,14 +193,13 @@ class ProjectFilter(filters.FilterSet): name = filters.CharFilter(field_name="name", lookup_expr="icontains") owner = filters.CharFilter(field_name="owner__username", 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 = models.Project - fields = ("id", "name", "owner", "status", "assignee") + fields = ("id", "name", "owner", "status") @method_decorator(name='list', decorator=swagger_auto_schema( - operation_summary='Returns a paginated list of projects according to query parameters (10 projects per page)', + operation_summary='Returns a paginated list of projects according to query parameters (12 projects per page)', manual_parameters=[ openapi.Parameter('id', openapi.IN_QUERY, description="A unique number value identifying this project", type=openapi.TYPE_NUMBER), @@ -208,21 +208,24 @@ class Meta: openapi.Parameter('owner', openapi.IN_QUERY, description="Find all project where owner name contains a parameter value", type=openapi.TYPE_STRING), openapi.Parameter('status', openapi.IN_QUERY, description="Find all projects with a specific status", - type=openapi.TYPE_STRING, enum=[str(i) for i in StatusChoice]), - openapi.Parameter('assignee', openapi.IN_QUERY, description="Find all projects where assignee name contains a parameter value", - type=openapi.TYPE_STRING)])) + type=openapi.TYPE_STRING, enum=[str(i) for i in StatusChoice])])) @method_decorator(name='create', decorator=swagger_auto_schema(operation_summary='Method creates a new project')) @method_decorator(name='retrieve', decorator=swagger_auto_schema(operation_summary='Method returns details of a specific project')) @method_decorator(name='destroy', decorator=swagger_auto_schema(operation_summary='Method deletes a specific project')) @method_decorator(name='partial_update', decorator=swagger_auto_schema(operation_summary='Methods does a partial update of chosen fields in a project')) class ProjectViewSet(auth.ProjectGetQuerySetMixin, viewsets.ModelViewSet): queryset = models.Project.objects.all().order_by('-id') - serializer_class = ProjectSerializer - search_fields = ("name", "owner__username", "assignee__username", "status") + search_fields = ("name", "owner__username", "status") filterset_class = ProjectFilter ordering_fields = ("id", "name", "owner", "status", "assignee") http_method_names = ['get', 'post', 'head', 'patch', 'delete'] + def get_serializer_class(self): + if self.request.query_params and self.request.query_params.get("names_only") == "true": + return ProjectSearchSerializer + else: + return ProjectSerializer + def get_permissions(self): http_method = self.request.method permissions = [IsAuthenticated] @@ -241,9 +244,19 @@ def get_permissions(self): return [perm() for perm in permissions] def perform_create(self, serializer): - if self.request.data.get('owner', None): + def validate_project_limit(owner): + admin_perm = auth.AdminRolePermission() + is_admin = admin_perm.has_permission(self.request, self) + if not is_admin and settings.RESTRICTIONS['project_limit'] is not None and \ + Project.objects.filter(owner=owner).count() >= settings.RESTRICTIONS['project_limit']: + raise serializers.ValidationError('The user has the maximum number of projects') + + owner = self.request.data.get('owner', None) + if owner: + validate_project_limit(owner) serializer.save() else: + validate_project_limit(self.request.user) serializer.save(owner=self.request.user) @swagger_auto_schema(method='get', operation_summary='Returns information of the tasks of the project with the selected id', diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 307570440945..8eb2a86097d1 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -346,6 +346,9 @@ def add_ssh_keys(): TASKS_ROOT = os.path.join(DATA_ROOT, 'tasks') os.makedirs(TASKS_ROOT, exist_ok=True) +PROJECTS_ROOT = os.path.join(DATA_ROOT, 'projects') +os.makedirs(PROJECTS_ROOT, exist_ok=True) + SHARE_ROOT = os.path.join(BASE_DIR, 'share') os.makedirs(SHARE_ROOT, exist_ok=True) @@ -427,6 +430,9 @@ def add_ssh_keys(): # this setting limits the number of tasks for the user 'task_limit': None, + # this setting limits the number of projects for the user + 'project_limit': None, + # this setting reduse task visibility to owner and assignee only 'reduce_task_visibility': False, diff --git a/tests/cypress/integration/actions_tasks_objects/case_3_task_start_stop_step_frame.js b/tests/cypress/integration/actions_tasks_objects/case_3_task_start_stop_step_frame.js index c7aded987b2b..70b11b76c280 100644 --- a/tests/cypress/integration/actions_tasks_objects/case_3_task_start_stop_step_frame.js +++ b/tests/cypress/integration/actions_tasks_objects/case_3_task_start_stop_step_frame.js @@ -34,6 +34,7 @@ context('Check if parameters "startFrame", "stopFrame", "frameStep" works as exp cy.login(); cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount); cy.createZipArchive(directoryToArchive, archivePath); + cy.goToTaskList(); }); after(() => { diff --git a/tests/cypress/integration/actions_users/case_1_create_delete_task.js b/tests/cypress/integration/actions_users/case_1_create_delete_task.js index a959747c75a9..8a2d496a8779 100644 --- a/tests/cypress/integration/actions_users/case_1_create_delete_task.js +++ b/tests/cypress/integration/actions_users/case_1_create_delete_task.js @@ -28,6 +28,7 @@ context('Create and delete a annotation task', () => { cy.login(); cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount); cy.createZipArchive(directoryToArchive, archivePath); + cy.goToTaskList(); }); describe(`Testing "${labelName}"`, () => { diff --git a/tests/cypress/integration/actions_users/case_4_assign_taks_job_users.js b/tests/cypress/integration/actions_users/case_4_assign_taks_job_users.js index 72a678b27882..260263662190 100644 --- a/tests/cypress/integration/actions_users/case_4_assign_taks_job_users.js +++ b/tests/cypress/integration/actions_users/case_4_assign_taks_job_users.js @@ -39,6 +39,7 @@ context('Multiple users. Assign task, job.', () => { after(() => { cy.login(); + cy.goToTaskList(); cy.getTaskID(taskName).then(($taskID) => { cy.deleteTask(taskName, $taskID); }); @@ -77,6 +78,7 @@ context('Multiple users. Assign task, job.', () => { it('First user login and create a task', () => { cy.login(); cy.url().should('include', '/tasks'); + cy.goToTaskList(); cy.imageGenerator(imagesFolder, imageFileName, width, height, color, posX, posY, labelName, imagesCount); cy.createZipArchive(directoryToArchive, archivePath); cy.createAnnotationTask(taskName, labelName, attrName, textDefaultValue, archiveName); @@ -92,7 +94,7 @@ context('Multiple users. Assign task, job.', () => { it('Second user login. The task can be opened. Logout', () => { cy.login(secondUserName, secondUser.password); cy.url().should('include', '/tasks'); - cy.get('[value="tasks"]').click(); + cy.goToTaskList(); cy.contains('strong', taskName).should('exist'); cy.openTask(taskName); cy.logout(secondUserName); @@ -100,14 +102,14 @@ context('Multiple users. Assign task, job.', () => { it('Third user login. The task not exist. Logout', () => { cy.login(thirdUserName, thirdUser.password); cy.url().should('include', '/tasks'); - cy.get('[value="tasks"]').click(); + cy.goToTaskList(); cy.contains('strong', taskName).should('not.exist'); cy.logout(thirdUserName); }); it('First user login and assign the job to the third user. Logout', () => { cy.login(); cy.url().should('include', '/tasks'); - cy.get('[value="tasks"]').click(); + cy.goToTaskList(); cy.openTask(taskName); cy.get('.cvat-task-job-list').within(() => { cy.get('.cvat-user-search-field').click({ force: true }); @@ -118,7 +120,7 @@ context('Multiple users. Assign task, job.', () => { it('Third user login. The task can be opened.', () => { cy.login(thirdUserName, thirdUser.password); cy.url().should('include', '/tasks'); - cy.get('[value="tasks"]').click(); + cy.goToTaskList(); cy.contains('strong', taskName).should('exist'); cy.openTask(taskName); cy.logout(thirdUserName); diff --git a/tests/cypress/support/const.js b/tests/cypress/support/const.js index 8b20e9fd1321..775896043f61 100644 --- a/tests/cypress/support/const.js +++ b/tests/cypress/support/const.js @@ -36,6 +36,7 @@ export const multiAttrParams = { it('Prepare to testing', () => { cy.visit('/'); cy.login(); + cy.goToTaskList(); cy.get('.cvat-tasks-page').should('exist'); let listItems = []; cy.document().then((doc) => { diff --git a/tests/cypress/support/index.js b/tests/cypress/support/index.js index ffc8d1895bd1..a1d7757fba66 100644 --- a/tests/cypress/support/index.js +++ b/tests/cypress/support/index.js @@ -6,7 +6,7 @@ require('./commands'); require('@cypress/code-coverage/support'); before(() => { - if (Cypress.browser.name === 'firefox') { + if (Cypress.browser.family !== 'chromium') { cy.visit('/'); cy.get('.ant-modal-body').within(() => { cy.get('.ant-modal-confirm-title').should('contain', 'Unsupported platform detected');