未验证 提交 879deb5a 编写于 作者: D Dmitry Kalinin 提交者: GitHub

Projects (#2255)

Initial implementation of Projects feature to group similar tasks into one group (aka Dataset UI in the future).
上级 9650e244
......@@ -30,3 +30,7 @@ yarn-debug.log*
yarn-error.log*
.DS_Store
#Ignore Cypress tests temp files
/tests/cypress/fixtures
/tests/cypress/screenshots
......@@ -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",
......
......@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-
- Added basic projects implementation (<https://github.com/openvinotoolkit/cvat/pull/2255>)
### Changed
......
......@@ -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;
}
......
......@@ -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);
......
// 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
* <br> It can contain keys:
* <br> <li style="margin-left: 10px;"> name
* <br> <li style="margin-left: 10px;"> 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;
};
})();
......@@ -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,
......
......@@ -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,
......
// 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);
});
});
......@@ -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', () => {
......
......@@ -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,
......
......@@ -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,
......
**/3rdparty/*.js
webpack.config.js
......@@ -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<Dispatch>): Promise<void> => {
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<Dispatch>): Promise<void> => {
const { jobInstance, filters, frame, showAllInterpolationTracks } = receiveAnnotationsParameters();
const {
jobInstance, filters, frame, showAllInterpolationTracks,
} = receiveAnnotationsParameters();
try {
if (statesToUpdate.some((state: any): boolean => state.updateFlags.zOrder)) {
......
// 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<ProjectsQuery>) => (
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<typeof projectActions>;
export function getProjectsAsync(query: Partial<ProjectsQuery>): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
dispatch(projectActions.getProjects());
dispatch(projectActions.updateProjectsGettingQuery(query));
// Clear query object from null fields
const filteredQuery: Partial<ProjectsQuery> = {
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<any>[] = [];
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<Dispatch>): Promise<void> => {
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<Dispatch>): Promise<void> => {
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<Dispatch>): Promise<void> => {
dispatch(projectActions.deleteProject(projectInstance.id));
try {
await projectInstance.delete();
dispatch(projectActions.deleteProjectSuccess(projectInstance.id));
} catch (error) {
dispatch(projectActions.deleteProjectFailed(projectInstance.id, error));
}
};
}
......@@ -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<Promise<void>, {},
}
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<Promise<void>, {}, {}, 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 },
......
// 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<FormComponentProps<any>, any, any> & WrappedFormUtils;
const ProjectNameEditor = Form.create<FormComponentProps>()(
(props: FormComponentProps): JSX.Element => {
const { form } = props;
const { getFieldDecorator } = form;
return (
<Form onSubmit={(e): void => e.preventDefault()}>
<Form.Item hasFeedback label={<span>Name</span>}>
{getFieldDecorator('name', {
rules: [
{
required: true,
message: 'Please, specify a name',
},
],
})(<Input />)}
</Form.Item>
</Form>
);
},
);
const AdvanvedConfigurationForm = Form.create<FormComponentProps>()(
(props: FormComponentProps): JSX.Element => {
const { form } = props;
const { getFieldDecorator } = form;
return (
<Form onSubmit={(e): void => e.preventDefault()}>
<Form.Item
label={<span>Issue tracker</span>}
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();
}
},
},
],
})(<Input />)}
</Form.Item>
</Form>
);
},
);
export default function CreateProjectContent(): JSX.Element {
const [projectLabels, setProjectLabels] = useState<any[]>([]);
const shouldShowNotification = useRef(false);
const nameFormRef = useRef<FormRefType>(null);
const advancedFormRef = useRef<FormRefType>(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 = <Button onClick={() => history.push(`/projects/${newProjectId}`)}>Open project</Button>;
// 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 (
<Row type='flex' justify='start' align='middle' className='cvat-create-project-content'>
<Col span={24}>
<ProjectNameEditor ref={nameFormRef} />
</Col>
<Col span={24}>
<Text className='cvat-text-color'>Labels:</Text>
<LabelsEditor
labels={projectLabels}
onSubmit={(newLabels): void => {
setProjectLabels(newLabels);
}}
/>
</Col>
<Col span={24}>
<AdvanvedConfigurationForm ref={advancedFormRef} />
</Col>
<Col span={24}>
<Button type='primary' onClick={onSumbit}>
Submit
</Button>
</Col>
</Row>
);
}
// 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 (
<Row type='flex' justify='center' align='top' className='cvat-create-task-form-wrapper'>
<Col md={20} lg={16} xl={14} xxl={9}>
<Text className='cvat-title'>Create a new project</Text>
<CreateProjectContent />
</Col>
</Row>
);
}
// 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;
}
}
}
......@@ -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<Props & RouteComponentProps,
this.state = { ...defaultState };
}
public componentDidMount(): void {
const { projectId } = this.props;
if (projectId) {
this.handleProjectIdChange(projectId);
}
}
public componentDidUpdate(prevProps: Props): void {
const { status, history, taskId } = this.props;
......@@ -87,9 +99,9 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
}
}
private validateLabels = (): boolean => {
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<Props & RouteComponentProps,
return !!totalLen;
};
private handleProjectIdChange = (value: null | number): void => {
this.setState({
projectId: value,
});
};
private handleSubmitBasicConfiguration = (values: BaseConfiguration): void => {
this.setState({
basic: { ...values },
......@@ -115,10 +133,10 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
};
private handleSubmitClick = (): void => {
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<Props & RouteComponentProps,
);
}
private renderProjectBlock(): JSX.Element {
const { projectId } = this.state;
return (
<>
<Col span={24}>
<Text className='cvat-text-color'>Project:</Text>
</Col>
<Col span={24}>
<ProjectSearchField onSelect={this.handleProjectIdChange} value={projectId} />
</Col>
</>
);
}
private renderLabelsBlock(): JSX.Element {
const { labels } = this.state;
const { projectId, labels } = this.state;
if (projectId) {
return (
<>
<Col span={24}>
<Text className='cvat-text-color'>Labels:</Text>
</Col>
<Col span={24}>
<Text type='secondary'>Project labels will be used</Text>
</Col>
</>
);
}
return (
<Col span={24}>
......@@ -231,12 +277,13 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
</Col>
{this.renderBasicBlock()}
{this.renderProjectBlock()}
{this.renderLabelsBlock()}
{this.renderFilesBlock()}
{this.renderAdvancedBlock()}
<Col span={18}>{loading ? <Alert message={status} /> : null}</Col>
<Col span={6}>
<Col span={6} className='cvat-create-task-submit-section'>
<Button loading={loading} disabled={loading} type='primary' onClick={this.handleSubmitClick}>
Submit
</Button>
......
......@@ -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 {
<Row type='flex' justify='center' align='top' className='cvat-create-task-form-wrapper'>
<Col md={20} lg={16} xl={14} xxl={9}>
<Text className='cvat-title'>Create a new task</Text>
<CreateTaskContent taskId={taskId} status={status} onCreate={onCreate} installedGit={installedGit} />
<CreateTaskContent
taskId={taskId}
projectId={projectId}
status={status}
onCreate={onCreate}
installedGit={installedGit}
/>
</Col>
</Row>
);
......
// 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<Project[]>([]);
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 (
<Autocomplete
value={searchPhrase}
placeholder='Select project'
onSearch={handleSearch}
onSelect={handleSelect}
className='cvat-project-search-field'
onDropdownVisibleChange={handleFocus}
dataSource={
projects.map((proj) => ({
value: proj.id.toString(),
text: proj.name,
}))
}
/>
);
}
......@@ -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%;
}
}
}
......@@ -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<CVATAppProps & RouteComponentP
<ShorcutsDialog />
<GlobalHotKeys keyMap={subKeyMap} handlers={handlers}>
<Switch>
<Route exact path='/projects' component={ProjectsPageComponent} />
<Route exact path='/projects/create' component={CreateProjectPageComponent} />
<Route exact path='/projects/:id' component={ProjectPageComponent} />
<Route exact path='/tasks' component={TasksPageContainer} />
<Route exact path='/tasks/create' component={CreateTaskPageContainer} />
<Route exact path='/tasks/:id' component={TaskPageContainer} />
......
......@@ -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 {
<div className='cvat-left-header'>
<Icon className='cvat-logo-icon' component={CVATLogo} />
<Button
className='cvat-header-button'
type='link'
value='projects'
href='/projects'
onClick={(event: React.MouseEvent): void => {
event.preventDefault();
history.push('/projects');
}}
>
Projects
</Button>
<Button
className='cvat-header-button'
type='link'
......@@ -288,8 +302,6 @@ function HeaderContainer(props: Props): JSX.Element {
href={GITHUB_URL}
onClick={(event: React.MouseEvent): void => {
event.preventDefault();
// false positive
// eslint-disable-next-line security/detect-non-literal-fs-filename
window.open(GITHUB_URL, '_blank');
}}
>
......
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import moment from 'moment';
import { Row, Col } from 'antd/lib/grid';
import Title from 'antd/lib/typography/Title';
import Text from 'antd/lib/typography/Text';
import getCore from 'cvat-core-wrapper';
import { Project } from 'reducers/interfaces';
import { updateProjectAsync } from 'actions/projects-actions';
import LabelsEditor from 'components/labels-editor/labels-editor';
import BugTrackerEditor from 'components/task-page/bug-tracker-editor';
import UserSelector from 'components/task-page/user-selector';
const core = getCore();
interface DetailsComponentProps {
project: Project;
}
export default function DetailsComponent(props: DetailsComponentProps): JSX.Element {
const { project } = props;
const dispatch = useDispatch();
const [projectName, setProjectName] = useState(project.name);
return (
<div className='cvat-project-details'>
<Row>
<Col>
<Title
level={4}
editable={{
onChange: (value: string): void => {
setProjectName(value);
project.name = value;
dispatch(updateProjectAsync(project));
},
}}
className='cvat-text-color'
>
{projectName}
</Title>
</Col>
</Row>
<Row type='flex' justify='space-between'>
<Col>
<Text type='secondary'>
{`Project #${project.id} created`}
{project.owner ? ` by ${project.owner.username}` : null}
{` on ${moment(project.createdDate).format('MMMM Do YYYY')}`}
</Text>
<BugTrackerEditor
instance={project}
onChange={(bugTracker): void => {
project.bugTracker = bugTracker;
dispatch(updateProjectAsync(project));
}}
/>
</Col>
<Col>
<Text type='secondary'>Assigned to</Text>
<UserSelector
value={project.assignee}
onSelect={(user) => {
project.assignee = user;
dispatch(updateProjectAsync(project));
}}
/>
</Col>
</Row>
<LabelsEditor
labels={project.labels.map((label: any): string => label.toJSON())}
onSubmit={(labels: any[]): void => {
project.labels = labels.map((labelData): any => new core.classes.Label(labelData));
dispatch(updateProjectAsync(project));
}}
/>
</div>
);
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useHistory, useParams } from 'react-router';
import Spin from 'antd/lib/spin';
import { Row, Col } from 'antd/lib/grid';
import Result from 'antd/lib/result';
import Button from 'antd/lib/button';
import Title from 'antd/lib/typography/Title';
import { CombinedState, Task } from 'reducers/interfaces';
import { getProjectsAsync } from 'actions/projects-actions';
import { cancelInferenceAsync } from 'actions/models-actions';
import TaskItem from 'components/tasks-page/task-item';
import DetailsComponent from './details';
import ProjectTopBar from './top-bar';
interface ParamType {
id: string;
}
export default function ProjectPageComponent(): JSX.Element {
const id = +useParams<ParamType>().id;
const dispatch = useDispatch();
const history = useHistory();
const projects = useSelector((state: CombinedState) => state.projects.current);
const projectsFetching = useSelector((state: CombinedState) => state.projects.fetching);
const deletes = useSelector((state: CombinedState) => state.projects.activities.deletes);
const taskDeletes = useSelector((state: CombinedState) => state.tasks.activities.deletes);
const tasksActiveInferences = useSelector((state: CombinedState) => state.models.inferences);
const tasks = useSelector((state: CombinedState) => state.tasks.current);
const filteredProjects = projects.filter((project) => project.id === id);
const project = filteredProjects[0];
const deleteActivity = project && id in deletes ? deletes[id] : null;
useEffect(() => {
dispatch(
getProjectsAsync({
id,
}),
);
}, [id, dispatch]);
if (deleteActivity) {
history.push('/projects');
}
if (projectsFetching) {
return <Spin size='large' className='cvat-spinner' />;
}
if (!project) {
return (
<Result
className='cvat-not-found'
status='404'
title='Sorry, but this project was not found'
subTitle='Please, be sure information you tried to get exist and you have access'
/>
);
}
return (
<Row type='flex' justify='center' align='top' className='cvat-project-page'>
<Col md={22} lg={18} xl={16} xxl={14}>
<ProjectTopBar projectInstance={project} />
<DetailsComponent project={project} />
<Row type='flex' justify='space-between' align='middle' className='cvat-project-page-tasks-bar'>
<Col>
<Title level={4}>Tasks</Title>
</Col>
<Col>
<Button
size='large'
type='primary'
icon='plus'
id='cvat-create-task-button'
onClick={() => history.push(`/tasks/create?projectId=${id}`)}
>
Create new task
</Button>
</Col>
</Row>
{tasks
.filter((task) => task.instance.projectId === project.id)
.map((task: Task) => (
<TaskItem
key={task.instance.id}
deleted={task.instance.id in taskDeletes ? taskDeletes[task.instance.id] : false}
hidden={false}
activeInference={tasksActiveInferences[task.instance.id] || null}
cancelAutoAnnotation={() => {
dispatch(cancelInferenceAsync(task.instance.id));
}}
previewImage={task.preview}
taskInstance={task.instance}
/>
))}
</Col>
</Row>
);
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-project-details {
width: 100%;
height: auto;
border: 1px solid $border-color-1;
border-radius: 3px;
padding: $grid-unit-size * 2;
margin: $grid-unit-size * 2 0;
background: $background-color-1;
.ant-row-flex:nth-child(1) {
margin-bottom: $grid-unit-size * 2;
}
.ant-row-flex:nth-child(2) .ant-col:nth-child(2) > span {
margin-right: $grid-unit-size;
}
.cvat-project-details-actions {
display: flex;
align-items: center;
justify-content: flex-end;
}
.cvat-issue-tracker {
margin-top: $grid-unit-size * 2;
margin-bottom: $grid-unit-size * 2;
}
}
.cvat-project-page-tasks-bar {
margin: $grid-unit-size * 2 0;
}
.ant-menu.cvat-project-actions-menu {
box-shadow: 0 0 17px rgba(0, 0, 0, 0.2);
> li:hover {
background-color: $hover-menu-color;
}
.ant-menu-submenu-title {
margin: 0;
width: 13em;
}
}
.cvat-project-top-bar-actions > button {
display: flex;
align-items: center;
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button';
import Dropdown from 'antd/lib/dropdown';
import Icon from 'antd/lib/icon';
import Text from 'antd/lib/typography/Text';
import { Project } from 'reducers/interfaces';
import ActionsMenu from 'components/projects-page/actions-menu';
import { MenuIcon } from 'icons';
interface DetailsComponentProps {
projectInstance: Project;
}
export default function ProjectTopBar(props: DetailsComponentProps): JSX.Element {
const { projectInstance } = props;
const history = useHistory();
return (
<Row className='cvat-task-top-bar' type='flex' justify='space-between' align='middle'>
<Col>
<Button onClick={() => history.push('/projects')} type='link' size='large'>
<Icon type='left' />
Back to projects
</Button>
</Col>
<Col className='cvat-project-top-bar-actions'>
<Dropdown overlay={<ActionsMenu projectInstance={projectInstance.instance} />}>
<Button size='large'>
<Text className='cvat-text-color'>Actions</Text>
<Icon className='cvat-menu-icon' component={MenuIcon} />
</Button>
</Dropdown>
</Col>
</Row>
);
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'antd/lib/modal';
import Menu from 'antd/lib/menu';
import { deleteProjectAsync } from 'actions/projects-actions';
interface Props {
projectInstance: any;
}
export default function ProjectActionsMenuComponent(props: Props): JSX.Element {
const { projectInstance } = props;
const dispatch = useDispatch();
const onDeleteProject = (): void => {
Modal.confirm({
title: `The project ${projectInstance.id} will be deleted`,
content: 'All related data (images, annotations) will be lost. Continue?',
onOk: () => {
dispatch(deleteProjectAsync(projectInstance));
},
okButtonProps: {
type: 'danger',
},
okText: 'Delete',
});
};
return (
<Menu className='cvat-project-actions-menu'>
<Menu.Item onClick={onDeleteProject}>Delete</Menu.Item>
</Menu>
);
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { Link } from 'react-router-dom';
import Text from 'antd/lib/typography/Text';
import { Row, Col } from 'antd/lib/grid';
import Icon from 'antd/lib/icon';
import { EmptyTasksIcon } from 'icons';
interface Props {
notFound?: boolean;
}
export default function EmptyListComponent(props: Props): JSX.Element {
const { notFound } = props;
return (
<div className='cvat-empty-projects-list'>
<Row type='flex' justify='center' align='middle'>
<Col>
<Icon className='cvat-empty-projects-icon' component={EmptyTasksIcon} />
</Col>
</Row>
{notFound ? (
<Row type='flex' justify='center' align='middle'>
<Col>
<Text strong>No results matched your search...</Text>
</Col>
</Row>
) : (
<>
<Row type='flex' justify='center' align='middle'>
<Col>
<Text strong>No projects created yet ...</Text>
</Col>
</Row>
<Row type='flex' justify='center' align='middle'>
<Col>
<Text type='secondary'>To get started with your annotation project</Text>
</Col>
</Row>
<Row type='flex' justify='center' align='middle'>
<Col>
<Link to='/projects/create'>create a new one</Link>
</Col>
</Row>
</>
)}
</div>
);
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import moment from 'moment';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import Text from 'antd/lib/typography/Text';
import Empty from 'antd/lib/empty';
import Card from 'antd/lib/card';
import Meta from 'antd/lib/card/Meta';
import Dropdown from 'antd/lib/dropdown';
import Button from 'antd/lib/button';
import { CombinedState, Project } from 'reducers/interfaces';
import ProjectActionsMenuComponent from './actions-menu';
interface Props {
projectInstance: Project;
}
export default function ProjectItemComponent(props: Props): JSX.Element {
const { projectInstance } = props;
const history = useHistory();
const ownerName = projectInstance.owner ? projectInstance.owner.username : null;
const updated = moment(projectInstance.updatedDate).fromNow();
const deletes = useSelector((state: CombinedState) => state.projects.activities.deletes);
const deleted = projectInstance.id in deletes ? deletes[projectInstance.id] : false;
let projectPreview = null;
if (projectInstance.tasks.length) {
// prettier-ignore
projectPreview = useSelector((state: CombinedState) => (
state.tasks.current.find((task) => task.instance.id === projectInstance.tasks[0].id)?.preview
));
}
const onOpenProject = (): void => {
history.push(`/projects/${projectInstance.id}`);
};
const style: React.CSSProperties = {};
if (deleted) {
style.pointerEvents = 'none';
style.opacity = 0.5;
}
return (
<Card
cover={
projectPreview ? (
<img
className='cvat-projects-project-item-card-preview'
src={projectPreview}
alt='Preview'
onClick={onOpenProject}
aria-hidden
/>
) : (
<div className='cvat-projects-project-item-card-preview' onClick={onOpenProject} aria-hidden>
<Empty description='No tasks' />
</div>
)
}
size='small'
style={style}
className='cvat-projects-project-item-card'
>
<Meta
title={(
<span onClick={onOpenProject} className='cvat-projects-project-item-title' aria-hidden>
{projectInstance.name}
</span>
)}
description={(
<div className='cvat-porjects-project-item-description'>
<div>
{ownerName && (
<>
<Text type='secondary'>{`Created ${ownerName ? `by ${ownerName}` : ''}`}</Text>
<br />
</>
)}
<Text type='secondary'>{`Last updated ${updated}`}</Text>
</div>
<div>
<Dropdown overlay={<ProjectActionsMenuComponent projectInstance={projectInstance} />}>
<Button type='link' size='large' icon='more' />
</Dropdown>
</div>
</div>
)}
/>
</Card>
);
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Row, Col } from 'antd/lib/grid';
import Pagination from 'antd/lib/pagination';
import { getProjectsAsync } from 'actions/projects-actions';
import { CombinedState } from 'reducers/interfaces';
import ProjectItem from './project-item';
export default function ProjectListComponent(): JSX.Element {
const dispatch = useDispatch();
const projectsCount = useSelector((state: CombinedState) => state.projects.count);
const { page } = useSelector((state: CombinedState) => state.projects.gettingQuery);
const projectInstances = useSelector((state: CombinedState) => state.projects.current);
const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery);
function changePage(p: number): void {
dispatch(
getProjectsAsync({
...gettingQuery,
page: p,
}),
);
}
return (
<>
<Row type='flex' justify='center' align='middle'>
<Col className='cvat-projects-list' md={22} lg={18} xl={16} xxl={14}>
<Row gutter={[8, 8]}>
{projectInstances.map(
(instance: any): JSX.Element => (
<Col xs={8} sm={8} xl={6} key={instance.id}>
<ProjectItem projectInstance={instance} />
</Col>
),
)}
</Row>
</Col>
</Row>
<Row type='flex' justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={14}>
<Pagination
className='cvat-projects-pagination'
onChange={changePage}
total={projectsCount}
pageSize={12}
current={page}
showQuickJumper
/>
</Col>
</Row>
</>
);
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation, useHistory } from 'react-router';
import Spin from 'antd/lib/spin';
import FeedbackComponent from 'components/feedback/feedback';
import { CombinedState, ProjectsQuery } from 'reducers/interfaces';
import { getProjectsAsync } from 'actions/projects-actions';
import EmptyListComponent from './empty-list';
import TopBarComponent from './top-bar';
import ProjectListComponent from './project-list';
export default function ProjectsPageComponent(): JSX.Element {
const { search } = useLocation();
const history = useHistory();
const dispatch = useDispatch();
const projectFetching = useSelector((state: CombinedState) => state.projects.fetching);
const projectsCount = useSelector((state: CombinedState) => state.projects.current.length);
const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery);
const anySearchQuery = !!Array.from(new URLSearchParams(search).keys()).filter((value) => value !== 'page').length;
useEffect(() => {
const searchParams: Partial<ProjectsQuery> = {};
for (const [param, value] of new URLSearchParams(search)) {
searchParams[param] = ['page', 'id'].includes(param) ? Number.parseInt(value, 10) : value;
}
dispatch(getProjectsAsync(searchParams));
}, []);
useEffect(() => {
const searchParams = new URLSearchParams();
for (const [name, value] of Object.entries(gettingQuery)) {
if (value !== null && typeof value !== 'undefined') {
searchParams.append(name, value.toString());
}
}
history.push({
pathname: '/projects',
search: `?${searchParams.toString()}`,
});
}, [gettingQuery]);
if (projectFetching) {
return (
<Spin size='large' className='cvat-spinner' />
);
}
return (
<div className='cvat-projects-page'>
<TopBarComponent />
{ projectsCount
? (
<ProjectListComponent />
) : (
<EmptyListComponent notFound={anySearchQuery} />
)}
<FeedbackComponent />
</div>
);
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Search from 'antd/lib/input/Search';
import { CombinedState, ProjectsQuery } from 'reducers/interfaces';
import { getProjectsAsync } from 'actions/projects-actions';
function getSearchField(gettingQuery: ProjectsQuery): string {
let searchString = '';
for (const field of Object.keys(gettingQuery)) {
if (gettingQuery[field] !== null && field !== 'page') {
if (field === 'search') {
return (gettingQuery[field] as any) as string;
}
// not constant condition
// eslint-disable-next-line
if (typeof (gettingQuery[field] === 'number')) {
searchString += `${field}:${gettingQuery[field]} AND `;
} else {
searchString += `${field}:"${gettingQuery[field]}" AND `;
}
}
}
return searchString.slice(0, -5);
}
export default function ProjectSearchField(): JSX.Element {
const dispatch = useDispatch();
const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery);
const handleSearch = (value: string): void => {
const query = { ...gettingQuery };
const search = value.replace(/\s+/g, ' ').replace(/\s*:+\s*/g, ':').trim();
const fields = Object.keys(query).filter((key) => key !== 'page');
for (const field of fields) {
query[field] = null;
}
query.search = null;
let specificRequest = false;
for (const param of search.split(/[\s]+and[\s]+|[\s]+AND[\s]+/)) {
if (param.includes(':')) {
const [field, fieldValue] = param.split(':');
if (fields.includes(field) && !!fieldValue) {
specificRequest = true;
if (field === 'id') {
if (Number.isInteger(+fieldValue)) {
query[field] = +fieldValue;
}
} else {
query[field] = fieldValue;
}
}
}
}
query.page = 1;
if (!specificRequest && value) {
query.search = value;
}
dispatch(getProjectsAsync(query));
};
return (
<Search
defaultValue={getSearchField(gettingQuery)}
onSearch={handleSearch}
size='large'
placeholder='Search'
/>
);
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-projects-page {
padding-top: $grid-unit-size * 2;
padding-bottom: $grid-unit-size * 5;
height: 100%;
position: fixed;
width: 100%;
> div:nth-child(1) {
padding-bottom: $grid-unit-size;
div > {
span {
color: $text-color;
}
}
}
}
/* empty-projects icon */
.cvat-empty-projects-list {
> div:nth-child(1) {
margin-top: $grid-unit-size * 6;
}
> div:nth-child(2) {
> div {
margin-top: $grid-unit-size * 3;
/* No projects created yet */
> span {
font-size: 20px;
color: $text-color;
}
}
}
/* To get started with your annotation project .. */
> div:nth-child(3) {
margin-top: $grid-unit-size;
}
}
.cvat-projects-top-bar {
> div:nth-child(1) {
display: flex;
> span:nth-child(2) {
width: $grid-unit-size * 25;
margin-left: $grid-unit-size;
}
}
> div:nth-child(2) {
display: flex;
justify-content: flex-end;
}
}
.cvat-create-project-button {
padding: 0 $grid-unit-size * 4;
}
.cvat-projects-pagination {
display: flex;
justify-content: center;
}
.cvat-projects-project-item-title,
.cvat-projects-project-item-card-preview {
cursor: pointer;
}
.cvat-porjects-project-item-description {
display: flex;
justify-content: space-between;
// actions button
> div:nth-child(2) {
display: flex;
align-self: flex-end;
justify-content: center;
> button {
color: $text-color;
width: inherit;
}
}
}
.ant-menu.cvat-project-actions-menu {
box-shadow: 0 0 17px rgba(0, 0, 0, 0.2);
> li:hover {
background-color: $hover-menu-color;
}
.ant-menu-submenu-title {
margin: 0;
width: 13em;
}
}
.cvat-projects-project-item-card {
.ant-empty {
margin: $grid-unit-size;
}
img {
height: 100%;
max-height: $grid-unit-size * 18;
object-fit: cover;
}
}
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button';
import Text from 'antd/lib/typography/Text';
import SearchField from './search-field';
export default function TopBarComponent(): JSX.Element {
const history = useHistory();
return (
<Row type='flex' justify='center' align='middle' className='cvat-projects-top-bar'>
<Col md={11} lg={9} xl={8} xxl={7}>
<Text className='cvat-title'>Projects</Text>
<SearchField />
</Col>
<Col md={{ span: 11 }} lg={{ span: 9 }} xl={{ span: 8 }} xxl={{ span: 7 }}>
<Button
size='large'
id='cvat-create-project-button'
className='cvat-create-project-button'
type='primary'
onClick={(): void => history.push('/projects/create')}
icon='plus'
>
Create new project
</Button>
</Col>
</Row>
);
}
......@@ -21,8 +21,7 @@ export interface ResetPasswordConfirmData {
type ResetPasswordConfirmFormProps = {
fetching: boolean;
onSubmit(resetPasswordConfirmData: ResetPasswordConfirmData): void;
} & FormComponentProps &
RouteComponentProps;
} & FormComponentProps & RouteComponentProps;
class ResetPasswordConfirmFormComponent extends React.PureComponent<ResetPasswordConfirmFormProps> {
private validateConfirmation = (_: any, value: string, callback: Function): void => {
......
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useState } from 'react';
import Button from 'antd/lib/button';
import Modal from 'antd/lib/modal';
import Text from 'antd/lib/typography/Text';
import { Row, Col } from 'antd/lib/grid';
import patterns from 'utils/validation-patterns';
interface Props {
instance: any;
onChange: (bugTracker: string) => void;
}
export default function BugTrackerEditorComponent(props: Props): JSX.Element {
const { instance, onChange } = props;
const [bugTracker, setBugTracker] = useState(instance.bugTracker);
const [bugTrackerEditing, setBugTrackerEditing] = useState(false);
const instanceType = Array.isArray(instance.tasks) ? 'project' : 'task';
let shown = false;
const onStart = (): void => setBugTrackerEditing(true);
const onChangeValue = (value: string): void => {
if (value && !patterns.validateURL.pattern.test(value)) {
if (!shown) {
Modal.error({
title: `Could not update the ${instanceType} ${instance.id}`,
content: 'Issue tracker is expected to be URL',
onOk: () => {
shown = false;
},
});
shown = true;
}
} else {
setBugTracker(value);
setBugTrackerEditing(false);
onChange(value);
}
};
if (bugTracker) {
return (
<Row className='cvat-issue-tracker'>
<Col>
<Text strong className='cvat-text-color'>
Issue Tracker
</Text>
<br />
<Text editable={{ onChange: onChangeValue }}>{bugTracker}</Text>
<Button
type='ghost'
size='small'
onClick={(): void => {
// false positive
// eslint-disable-next-line
window.open(bugTracker, '_blank');
}}
className='cvat-open-bug-tracker-button'
>
Open the issue
</Button>
</Col>
</Row>
);
}
return (
<Row className='cvat-issue-tracker'>
<Col>
<Text strong className='cvat-text-color'>
Issue Tracker
</Text>
<br />
<Text
editable={{
editing: bugTrackerEditing,
onStart,
onChange: onChangeValue,
}}
>
{bugTrackerEditing ? '' : 'Not specified'}
</Text>
</Col>
</Row>
);
}
......@@ -7,18 +7,17 @@ import { Row, Col } from 'antd/lib/grid';
import Tag from 'antd/lib/tag';
import Icon from 'antd/lib/icon';
import Modal from 'antd/lib/modal';
import Button from 'antd/lib/button';
import notification from 'antd/lib/notification';
import Text from 'antd/lib/typography/Text';
import Title from 'antd/lib/typography/Title';
import moment from 'moment';
import getCore from 'cvat-core-wrapper';
import patterns from 'utils/validation-patterns';
import { getReposData, syncRepos } from 'utils/git-utils';
import { ActiveInference } from 'reducers/interfaces';
import AutomaticAnnotationProgress from 'components/tasks-page/automatic-annotation-progress';
import UserSelector, { User } from './user-selector';
import BugTrackerEditor from './bug-tracker-editor';
import LabelsEditorComponent from '../labels-editor/labels-editor';
const core = getCore();
......@@ -34,8 +33,6 @@ interface Props {
interface State {
name: string;
bugTracker: string;
bugTrackerEditing: boolean;
repository: string;
repositoryStatus: string;
}
......@@ -57,8 +54,6 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
this.previewWrapperRef = React.createRef<HTMLDivElement>();
this.state = {
name: taskInstance.name,
bugTracker: taskInstance.bugTracker,
bugTrackerEditing: false,
repository: '',
repositoryStatus: '',
};
......@@ -119,7 +114,6 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
if (prevProps !== this.props) {
this.setState({
name: taskInstance.name,
bugTracker: taskInstance.bugTracker,
});
}
}
......@@ -194,7 +188,7 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
);
}
private renderUsers(): JSX.Element {
private renderDescription(): JSX.Element {
const { taskInstance, onTaskUpdate } = this.props;
const owner = taskInstance.owner ? taskInstance.owner.username : null;
const assignee = taskInstance.assignee ? taskInstance.assignee : null;
......@@ -211,7 +205,11 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
return (
<Row className='cvat-task-details-user-block' type='flex' justify='space-between' align='middle'>
<Col span={12}>{owner && <Text type='secondary'>{`Created by ${owner} on ${created}`}</Text>}</Col>
<Col span={12}>
{owner && (
<Text type='secondary'>{`Task #${taskInstance.id} Created by ${owner} on ${created}`}</Text>
)}
</Col>
<Col span={10}>
<Text type='secondary'>Assigned to</Text>
{assigneeSelect}
......@@ -294,86 +292,6 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
);
}
private renderBugTracker(): JSX.Element {
const { taskInstance, onTaskUpdate } = this.props;
const { bugTracker, bugTrackerEditing } = this.state;
let shown = false;
const onStart = (): void => {
this.setState({
bugTrackerEditing: true,
});
};
const onChangeValue = (value: string): void => {
if (value && !patterns.validateURL.pattern.test(value)) {
if (!shown) {
Modal.error({
title: `Could not update the task ${taskInstance.id}`,
content: 'Issue tracker is expected to be URL',
onOk: () => {
shown = false;
},
});
shown = true;
}
} else {
this.setState({
bugTracker: value,
bugTrackerEditing: false,
});
taskInstance.bugTracker = value;
onTaskUpdate(taskInstance);
}
};
if (bugTracker) {
return (
<Row>
<Col>
<Text strong className='cvat-text-color'>
Issue Tracker
</Text>
<br />
<Text editable={{ onChange: onChangeValue }}>{bugTracker}</Text>
<Button
type='ghost'
size='small'
onClick={(): void => {
// false positive
// eslint-disable-next-line
window.open(bugTracker, '_blank');
}}
className='cvat-open-bug-tracker-button'
>
Open the issue
</Button>
</Col>
</Row>
);
}
return (
<Row>
<Col>
<Text strong className='cvat-text-color'>
Issue Tracker
</Text>
<br />
<Text
editable={{
editing: bugTrackerEditing,
onStart,
onChange: onChangeValue,
}}
>
{bugTrackerEditing ? '' : 'Not specified'}
</Text>
</Col>
</Row>
);
}
private renderLabelsEditor(): JSX.Element {
const { taskInstance, onTaskUpdate } = this.props;
......@@ -393,7 +311,10 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
}
public render(): JSX.Element {
const { activeInference, cancelAutoAnnotation } = this.props;
const {
activeInference, cancelAutoAnnotation, taskInstance, onTaskUpdate,
} = this.props;
return (
<div className='cvat-task-details'>
<Row type='flex' justify='start' align='middle'>
......@@ -409,9 +330,17 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
</Row>
</Col>
<Col md={16} lg={17} xl={17} xxl={18}>
{this.renderUsers()}
{this.renderDescription()}
<Row type='flex' justify='space-between' align='middle'>
<Col span={12}>{this.renderBugTracker()}</Col>
<Col span={12}>
<BugTrackerEditor
instance={taskInstance}
onChange={(bugTracker) => {
taskInstance.bugTracker = bugTracker;
onTaskUpdate(taskInstance);
}}
/>
</Col>
<Col span={10}>
<AutomaticAnnotationProgress
activeInference={activeInference}
......@@ -420,7 +349,7 @@ export default class DetailsComponent extends React.PureComponent<Props, State>
</Col>
</Row>
{this.renderDatasetRepository()}
{this.renderLabelsEditor()}
{!taskInstance.projectId && this.renderLabelsEditor()}
</Col>
</Row>
</div>
......
......@@ -78,8 +78,7 @@
}
.cvat-task-top-bar {
margin-top: 20px;
margin-bottom: 10px;
margin: $grid-unit-size * 2 0;
}
.cvat-task-preview-wrapper {
......
......@@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT
import React from 'react';
import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button';
import Dropdown from 'antd/lib/dropdown';
......@@ -18,12 +19,27 @@ interface DetailsComponentProps {
export default function DetailsComponent(props: DetailsComponentProps): JSX.Element {
const { taskInstance } = props;
const { id } = taskInstance;
const history = useHistory();
return (
<Row className='cvat-task-top-bar' type='flex' justify='space-between' align='middle'>
<Col>
<Text className='cvat-title'>{`Task details #${id}`}</Text>
{taskInstance.projectId ? (
<Button
onClick={() => history.push(`/projects/${taskInstance.projectId}`)}
type='link'
size='large'
>
<Icon type='left' />
Back to project
</Button>
) : (
<Button onClick={() => history.push('/tasks')} type='link' size='large'>
<Icon type='left' />
Back to tasks
</Button>
)}
</Col>
<Col>
<Dropdown overlay={<ActionsMenuContainer taskInstance={taskInstance} />}>
......
......@@ -31,6 +31,8 @@ export default function EmptyListComponent(): JSX.Element {
<Row type='flex' justify='center' align='middle'>
<Col>
<Link to='/tasks/create'>create a new task</Link>
<Text type='secondary'> or try to </Text>
<Link to='/projects/create'>create a new project</Link>
</Col>
</Row>
</div>
......
......@@ -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;
......
......@@ -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 (
<>
<Row type='flex' justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={14}>
<Text strong>Default project</Text>
</Col>
</Row>
<Row type='flex' justify='center' align='middle'>
<Col md={11} lg={9} xl={8} xxl={7}>
<Text className='cvat-title'>Tasks</Text>
......@@ -45,5 +41,3 @@ function TopBarComponent(props: VisibleTopBarProps & RouteComponentProps): JSX.E
</>
);
}
export default withRouter(TopBarComponent);
......@@ -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;
......
......@@ -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 ' +
`<a href="/project/${projectId}" target="_blank">project ${projectId}</a>`,
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 ' +
`<a href="/project/${projectId}" target="_blank">project ${projectId}</a>`,
reason: action.payload.error.toString(),
},
},
},
};
}
case FormatsActionTypes.GET_FORMATS_FAILED: {
return {
...state,
......
// 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;
}
};
......@@ -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,
......
......@@ -42,7 +42,7 @@ class TaskData:
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(
......
......@@ -93,7 +93,8 @@ class JobAnnotation:
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():
......
......@@ -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 @@ class SegmentAdmin(admin.ModelAdmin):
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 @@ class TaskAdmin(admin.ModelAdmin):
admin.site.register(Task, TaskAdmin)
admin.site.register(Segment, SegmentAdmin)
admin.site.register(Label, LabelAdmin)
admin.site.register(Project, ProjectAdmin)
......@@ -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 @@ class JobLoggerStorage:
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'),
......
# 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'),
),
]
......@@ -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 Job(models.Model):
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):
......
......@@ -74,6 +74,47 @@ class LabelSerializer(serializers.ModelSerializer):
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 @@ class DataSerializer(serializers.ModelSerializer):
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 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer):
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 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer):
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)
......
......@@ -29,7 +29,7 @@ from rest_framework import status
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 @@ class ProjectGetAPITestCase(APITestCase):
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 @@ class ProjectCreateAPITestCase(APITestCase):
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 @@ class ProjectCreateAPITestCase(APITestCase):
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 @@ class ProjectCreateAPITestCase(APITestCase):
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 @@ class ProjectCreateAPITestCase(APITestCase):
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 @@ class ProjectPartialUpdateAPITestCase(APITestCase):
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 @@ class ProjectPartialUpdateAPITestCase(APITestCase):
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 @@ class TaskPartialUpdateAPITestCase(TaskUpdateAPITestCase):
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 @@ class TaskCreateAPITestCase(APITestCase):
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 @@ class TaskCreateAPITestCase(APITestCase):
}
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",
......
......@@ -36,13 +36,14 @@ import cvat.apps.dataset_manager.views # pylint: disable=unused-import
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 ProjectFilter(filters.FilterSet):
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 @@ class ProjectViewSet(auth.ProjectGetQuerySetMixin, viewsets.ModelViewSet):
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',
......
......@@ -346,6 +346,9 @@ os.makedirs(CACHE_ROOT, exist_ok=True)
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 @@ RESTRICTIONS = {
# 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,
......
......@@ -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(() => {
......
......@@ -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}"`, () => {
......
......@@ -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);
......
......@@ -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) => {
......
......@@ -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');
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册