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

Task moving between projects (#3164)

上级 e0f10d7b
......@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Hotkeys to switch a label of existing object or to change default label (for objects created with N) (<https://github.com/openvinotoolkit/cvat/pull/3070>)
- A script to convert some kinds of DICOM files to regular images (<https://github.com/openvinotoolkit/cvat/pull/3095>)
- Helm chart prototype (<https://github.com/openvinotoolkit/cvat/pull/3102>)
- Initial implementation of moving tasks between projects (<https://github.com/openvinotoolkit/cvat/pull/3164>)
### Changed
......
{
"name": "cvat-core",
"version": "3.13.0",
"version": "3.12.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
......
{
"name": "cvat-core",
"version": "3.13.0",
"version": "3.12.3",
"description": "Part of Computer Vision Tool which presents an interface for client-side integration",
"main": "babel.config.js",
"scripts": {
......
......@@ -104,6 +104,37 @@
}
negativeIDGenerator.start = -1;
class FieldUpdateTrigger {
constructor(initialFields) {
const data = { ...initialFields };
Object.defineProperties(
this,
Object.freeze({
...Object.assign(
{},
...Array.from(Object.keys(data), (key) => ({
[key]: {
get: () => data[key],
set: (value) => {
data[key] = value;
},
enumerable: true,
},
})),
),
reset: {
value: () => {
Object.keys(data).forEach((key) => {
data[key] = false;
});
},
},
}),
);
}
}
module.exports = {
isBoolean,
isInteger,
......@@ -114,5 +145,6 @@
negativeIDGenerator,
checkExclusiveFields,
camelToSnake,
FieldUpdateTrigger,
};
})();
......@@ -1082,7 +1082,9 @@
const closureId = Date.now();
predictAnnotations.latestRequest.id = closureId;
const predicate = () => !predictAnnotations.latestRequest.fetching || predictAnnotations.latestRequest.id !== closureId;
const predicate = () => (
!predictAnnotations.latestRequest.fetching || predictAnnotations.latestRequest.id !== closureId
);
if (predictAnnotations.latestRequest.fetching) {
waitFor(5, predicate).then(() => {
if (predictAnnotations.latestRequest.id !== closureId) {
......
......@@ -16,6 +16,7 @@
const User = require('./user');
const Issue = require('./issue');
const Review = require('./review');
const { FieldUpdateTrigger } = require('./common');
function buildDublicatedAPI(prototype) {
Object.defineProperties(prototype, {
......@@ -734,11 +735,11 @@
task: undefined,
};
let updatedFields = {
const updatedFields = new FieldUpdateTrigger({
assignee: false,
reviewer: false,
status: false,
};
});
for (const property in data) {
if (Object.prototype.hasOwnProperty.call(data, property)) {
......@@ -865,9 +866,6 @@
},
__updatedFields: {
get: () => updatedFields,
set: (fields) => {
updatedFields = fields;
},
},
}),
);
......@@ -1040,13 +1038,14 @@
dimension: undefined,
};
let updatedFields = {
const updatedFields = new FieldUpdateTrigger({
name: false,
assignee: false,
bug_tracker: false,
subset: false,
labels: false,
};
project_id: false,
});
for (const property in data) {
if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) {
......@@ -1126,11 +1125,18 @@
* @name projectId
* @type {integer|null}
* @memberof module:API.cvat.classes.Task
* @readonly
* @instance
*/
projectId: {
get: () => data.project_id,
set: (projectId) => {
if (!Number.isInteger(projectId) || projectId <= 0) {
throw new ArgumentError('Value must be a positive integer');
}
updatedFields.project_id = true;
data.project_id = projectId;
},
},
/**
* @name status
......@@ -1558,9 +1564,6 @@
},
__updatedFields: {
get: () => updatedFields,
set: (fields) => {
updatedFields = fields;
},
},
}),
);
......@@ -1721,11 +1724,7 @@
await serverProxy.jobs.save(this.id, jobData);
this.__updatedFields = {
status: false,
assignee: false,
reviewer: false,
};
this.__updatedFields.reset();
return this;
}
......@@ -2000,6 +1999,9 @@
case 'subset':
taskData.subset = this.subset;
break;
case 'project_id':
taskData.project_id = this.projectId;
break;
case 'labels':
taskData.labels = [...this._internalData.labels.map((el) => el.toJSON())];
break;
......@@ -2011,13 +2013,7 @@
await serverProxy.tasks.saveTask(this.id, taskData);
this.updatedFields = {
assignee: false,
name: false,
bugTracker: false,
subset: false,
labels: false,
};
this.__updatedFields.reset();
return this;
}
......
......@@ -35,6 +35,7 @@ export enum TasksActionTypes {
UPDATE_TASK_SUCCESS = 'UPDATE_TASK_SUCCESS',
UPDATE_TASK_FAILED = 'UPDATE_TASK_FAILED',
HIDE_EMPTY_TASKS = 'HIDE_EMPTY_TASKS',
SWITCH_MOVE_TASK_MODAL_VISIBLE = 'SWITCH_MOVE_TASK_MODAL_VISIBLE',
}
function getTasks(): AnyAction {
......@@ -519,3 +520,46 @@ export function hideEmptyTasks(hideEmpty: boolean): AnyAction {
return action;
}
export function switchMoveTaskModalVisible(visible: boolean, taskId: number | null = null): AnyAction {
const action = {
type: TasksActionTypes.SWITCH_MOVE_TASK_MODAL_VISIBLE,
payload: {
taskId,
visible,
},
};
return action;
}
interface LabelMap {
label_id: number;
new_label_name: string | null;
clear_attributes: boolean;
}
export function moveTaskToProjectAsync(
taskInstance: any,
projectId: any,
labelMap: LabelMap[],
): ThunkAction<Promise<void>, {}, {}, AnyAction> {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
dispatch(updateTask());
try {
// eslint-disable-next-line no-param-reassign
taskInstance.labels = labelMap.map((mapper) => {
const [label] = taskInstance.labels.filter((_label: any) => mapper.label_id === _label.id);
label.name = mapper.new_label_name;
return label;
});
// eslint-disable-next-line no-param-reassign
taskInstance.projectId = projectId;
await taskInstance.save();
const [task] = await cvat.tasks.get({ id: taskInstance.id });
dispatch(updateTaskSuccess(task, task.id));
} catch (error) {
dispatch(updateTaskFailed(error, taskInstance));
}
};
}
......@@ -13,6 +13,7 @@ $layout-lg-grid-color: rgba(0, 0, 0, 0.15);
$header-color: #d8d8d8;
$text-color: #303030;
$text-color-secondary: rgba(0, 0, 0, 0.45);
$hover-menu-color: rgba(24, 144, 255, 0.05);
$completed-progress-color: #61c200;
$inprogress-progress-color: #1890ff;
......
......@@ -33,6 +33,7 @@ export enum Actions {
EXPORT_TASK_DATASET = 'export_task_dataset',
DELETE_TASK = 'delete_task',
RUN_AUTO_ANNOTATION = 'run_auto_annotation',
MOVE_TASK_TO_PROJECT = 'move_task_to_project',
OPEN_BUG_TRACKER = 'open_bug_tracker',
}
......@@ -128,6 +129,7 @@ export default function ActionsMenuComponent(props: Props): JSX.Element {
Automatic annotation
</Menu.Item>
<hr />
<Menu.Item key={Actions.MOVE_TASK_TO_PROJECT}>Move to project</Menu.Item>
<Menu.Item key={Actions.DELETE_TASK}>Delete</Menu.Item>
</Menu>
);
......
......@@ -47,7 +47,12 @@ export default function LoadSubmenu(props: Props): JSX.Element {
return false;
}}
>
<Button block type='link' disabled={disabled} className='cvat-menu-load-submenu-item-button'>
<Button
block
type='link'
disabled={disabled}
className='cvat-menu-load-submenu-item-button'
>
<UploadOutlined />
<Text>{loader.name}</Text>
{pending && <LoadingOutlined style={{ marginLeft: 10 }} />}
......
......@@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: MIT
import React, { useEffect, useState } from 'react';
import React, { useState, useEffect } from 'react';
import { connect } from 'react-redux';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
import { SelectValue } from 'antd/lib/select';
......
......@@ -13,6 +13,7 @@ const core = getCore();
type Props = {
value: number | null;
onSelect: (id: number | null) => void;
filter?: (value: Project, index: number, array: Project[]) => unknown
};
type Project = {
......@@ -21,7 +22,7 @@ type Project = {
};
export default function ProjectSearchField(props: Props): JSX.Element {
const { value, onSelect } = props;
const { value, filter, onSelect } = props;
const [searchPhrase, setSearchPhrase] = useState('');
const [projects, setProjects] = useState<Project[]>([]);
......@@ -43,8 +44,12 @@ export default function ProjectSearchField(props: Props): JSX.Element {
const handleFocus = (open: boolean): void => {
if (!projects.length && open) {
core.projects.searchNames().then((result: Project[]) => {
if (result) {
setProjects(result);
let projectsResponse = result;
if (typeof filter === 'function') {
projectsResponse = projectsResponse.filter(filter);
}
if (projectsResponse) {
setProjects(projectsResponse);
}
});
}
......
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { Col, Row } from 'antd/lib/grid';
import Tag from 'antd/lib/tag';
import Select from 'antd/lib/select';
import Checkbox from 'antd/lib/checkbox';
import { ArrowRightOutlined } from '@ant-design/icons';
import CVATTooltip from 'components/common/cvat-tooltip';
export interface LabelMapperItemValue {
labelId: number;
newLabelName: string | null;
clearAtrributes: boolean;
}
export interface LabelMapperItemProps {
label: any;
projectLabels?: any[];
value: LabelMapperItemValue;
labelMappers: LabelMapperItemValue[];
onChange: (value: LabelMapperItemValue) => void;
}
export default function LabelMapperItem(props: LabelMapperItemProps): JSX.Element {
const {
label, value, onChange, projectLabels, labelMappers,
} = props;
const labelNames = labelMappers.map((mapper) => mapper.newLabelName).filter((el) => el);
return (
<Row className='cvat-move-task-label-mapper-item' align='middle'>
<Col span={6}>
{label.name.length > 12 ? (
<CVATTooltip overlay={label.name}>
<Tag color={label.color}>
{`${label.name.slice(0, 12)}...`}
</Tag>
</CVATTooltip>
) : (
<Tag color={label.color}>
{label.name}
</Tag>
)}
<ArrowRightOutlined />
</Col>
<Col>
<Select
disabled={typeof projectLabels === 'undefined'}
value={value.newLabelName || ''}
onChange={(_value) =>
onChange({
...value,
newLabelName: _value as string,
})}
>
{projectLabels?.filter((_label) => (
!labelNames.includes(_label.name)
)).map((_label) => (
<Select.Option key={_label.id} value={_label.name}>
{_label.name}
</Select.Option>
))}
</Select>
</Col>
<Col>
<Checkbox
disabled
checked={value.clearAtrributes}
onChange={(_value) =>
onChange({
...value,
clearAtrributes: _value.target.checked,
})}
>
Clear attributes
</Checkbox>
</Col>
</Row>
);
}
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Modal from 'antd/lib/modal';
import { Row, Col } from 'antd/lib/grid';
import Divider from 'antd/lib/divider';
import notification from 'antd/lib/notification';
import { QuestionCircleFilled } from '@ant-design/icons';
import ProjectSearch from 'components/create-task-page/project-search-field';
import CVATTooltip from 'components/common/cvat-tooltip';
import { CombinedState } from 'reducers/interfaces';
import { switchMoveTaskModalVisible, moveTaskToProjectAsync } from 'actions/tasks-actions';
import getCore from 'cvat-core-wrapper';
import LabelMapperItem, { LabelMapperItemValue } from './label-mapper-item';
const core = getCore();
export default function MoveTaskModal(): JSX.Element {
const visible = useSelector((state: CombinedState) => state.tasks.moveTask.modalVisible);
const task = useSelector((state: CombinedState) => {
const [taskInstance] = state.tasks.current.filter((_task) => _task.instance.id === state.tasks.moveTask.taskId);
return taskInstance?.instance;
});
const taskUpdating = useSelector((state: CombinedState) => state.tasks.updating);
const dispatch = useDispatch();
const [projectId, setProjectId] = useState<number | null>(null);
const [project, setProject] = useState<any>(null);
const [labelMap, setLabelMap] = useState<{ [key: string]: LabelMapperItemValue }>({});
const initValues = (): void => {
if (task) {
const labelValues: { [key: string]: LabelMapperItemValue } = {};
task.labels.forEach((label: any) => {
labelValues[label.id] = {
labelId: label.id,
newLabelName: null,
clearAtrributes: true,
};
});
setLabelMap(labelValues);
}
};
const onCancel = (): void => {
dispatch(switchMoveTaskModalVisible(false));
initValues();
setProject(null);
setProjectId(null);
};
const submitMove = async (): Promise<void> => {
if (!projectId) {
notification.error({
message: 'Project not selected',
});
return;
}
if (!Object.values(labelMap).every((map) => map.newLabelName !== null)) {
notification.error({
message: 'Not all labels mapped',
description: 'Please choose any action to not mapped labels first',
});
return;
}
dispatch(
moveTaskToProjectAsync(
task,
projectId,
Object.values(labelMap).map((map) => ({
label_id: map.labelId,
new_label_name: map.newLabelName,
clear_attributes: map.clearAtrributes,
})),
),
);
onCancel();
};
useEffect(() => {
if (projectId) {
core.projects.get({ id: projectId }).then((_project: any) => {
if (projectId) {
setProject(_project[0]);
const { labels } = _project[0];
const labelValues: { [key: string]: LabelMapperItemValue } = {};
Object.entries(labelMap).forEach(([id, label]) => {
const taskLabelName = task.labels.filter(
(_label: any) => (_label.id === label.labelId),
)[0].name;
const [autoNewLabel] = labels.filter((_label: any) => (
_label.name === taskLabelName
));
labelValues[id] = {
labelId: label.labelId,
newLabelName: autoNewLabel ? autoNewLabel.name : null,
clearAtrributes: true,
};
});
setLabelMap(labelValues);
}
});
} else {
setProject(null);
}
}, [projectId]);
useEffect(() => {
initValues();
}, [task?.id]);
return (
<Modal
visible={visible}
onCancel={onCancel}
onOk={submitMove}
okButtonProps={{ disabled: taskUpdating }}
title={(
<span>
{`Move task ${task?.id} to project`}
{/* TODO: replace placeholder */}
<CVATTooltip title='Some moving proccess description here'>
<QuestionCircleFilled className='ant-typography-secondary' />
</CVATTooltip>
</span>
)}
className='cvat-task-move-modal'
>
<Row align='middle'>
<Col>Project:</Col>
<Col>
<ProjectSearch
value={projectId}
onSelect={setProjectId}
filter={(_project) => _project.id !== task?.projectId}
/>
</Col>
</Row>
<Divider orientation='left'>Label mapping</Divider>
{!!Object.keys(labelMap).length && !taskUpdating &&
task?.labels.map((label: any) => (
<LabelMapperItem
label={label}
key={label.id}
projectLabels={project?.labels}
value={labelMap[label.id]}
labelMappers={Object.values(labelMap)}
onChange={(value) => {
setLabelMap({
...labelMap,
[value.labelId]: value,
});
}}
/>
))}
</Modal>
);
}
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import 'base.scss';
.cvat-task-move-modal > .ant-modal-content {
> .ant-modal-body {
> div:nth-child(1) {
margin-bottom: $grid-unit-size * 2;
> div:nth-child(1) {
padding-right: $grid-unit-size * 2;
}
}
}
> .ant-modal-header .anticon {
margin-left: $grid-unit-size;
> svg {
color: $text-color-secondary;
}
}
.ant-select {
margin: 0 $grid-unit-size;
width: $grid-unit-size * 25;
}
}
.cvat-move-task-label-mapper-item {
margin: $grid-unit-size * 2 0;
}
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -13,6 +13,7 @@ import Result from 'antd/lib/result';
import DetailsContainer from 'containers/task-page/details';
import JobListContainer from 'containers/task-page/job-list';
import ModelRunnerModal from 'components/model-runner-modal/model-runner-dialog';
import MoveTaskModal from 'components/move-task-modal/move-task-modal';
import { Task } from 'reducers/interfaces';
import TopBarComponent from './top-bar';
......@@ -83,6 +84,7 @@ class TaskPageComponent extends React.PureComponent<Props> {
</Col>
</Row>
<ModelRunnerModal />
<MoveTaskModal />
{updating && <Spin size='large' className='cvat-spinner' />}
</>
);
......
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -7,6 +7,7 @@ import { Row, Col } from 'antd/lib/grid';
import Pagination from 'antd/lib/pagination';
import ModelRunnerModal from 'components/model-runner-modal/model-runner-dialog';
import MoveTaskModal from 'components/move-task-modal/move-task-modal';
import TaskItem from 'containers/tasks-page/task-item';
export interface ContentListProps {
......@@ -43,6 +44,7 @@ export default function TaskListComponent(props: ContentListProps): JSX.Element
</Col>
</Row>
<ModelRunnerModal />
<MoveTaskModal />
</>
);
}
......@@ -4,16 +4,20 @@
import React from 'react';
import { connect } from 'react-redux';
// eslint-disable-next-line import/no-extraneous-dependencies
import { MenuInfo } from 'rc-menu/lib/interface';
import ActionsMenuComponent, { Actions } from 'components/actions-menu/actions-menu';
import { CombinedState } from 'reducers/interfaces';
import { modelsActions } from 'actions/models-actions';
import {
dumpAnnotationsAsync, loadAnnotationsAsync, exportDatasetAsync, deleteTaskAsync,
dumpAnnotationsAsync,
loadAnnotationsAsync,
exportDatasetAsync,
deleteTaskAsync,
switchMoveTaskModalVisible,
} from 'actions/tasks-actions';
// eslint-disable-next-line import/no-extraneous-dependencies
import { MenuInfo } from 'rc-menu/lib/interface';
interface OwnProps {
taskInstance: any;
......@@ -33,6 +37,7 @@ interface DispatchToProps {
exportDataset: (taskInstance: any, exporter: any) => void;
deleteTask: (taskInstance: any) => void;
openRunModelWindow: (taskInstance: any) => void;
openMoveTaskToProjectWindow: (taskInstance: any) => void;
}
function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps {
......@@ -73,6 +78,9 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
openRunModelWindow: (taskInstance: any): void => {
dispatch(modelsActions.showRunModelDialog(taskInstance));
},
openMoveTaskToProjectWindow: (taskId: number): void => {
dispatch(switchMoveTaskModalVisible(true, taskId));
},
};
}
......@@ -90,6 +98,7 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps):
exportDataset,
deleteTask,
openRunModelWindow,
openMoveTaskToProjectWindow,
} = props;
function onClickMenu(params: MenuInfo, file?: File): void {
......@@ -119,10 +128,11 @@ function ActionsMenuContainer(props: OwnProps & StateToProps & DispatchToProps):
if (action === Actions.DELETE_TASK) {
deleteTask(taskInstance);
} else if (action === Actions.OPEN_BUG_TRACKER) {
// eslint-disable-next-line
window.open(`${taskInstance.bugTracker}`, '_blank');
} else if (action === Actions.RUN_AUTO_ANNOTATION) {
openRunModelWindow(taskInstance);
} else if (action === Actions.MOVE_TASK_TO_PROJECT) {
openMoveTaskToProjectWindow(taskInstance.id);
}
}
}
......
......@@ -8,6 +8,7 @@ import { withRouter } from 'react-router';
import { RouteComponentProps } from 'react-router-dom';
import Input from 'antd/lib/input';
import copy from 'copy-to-clipboard';
import {
activateObject,
changeFrameAsync,
......
......@@ -74,6 +74,10 @@ export interface TasksState {
fetching: boolean;
updating: boolean;
hideEmpty: boolean;
moveTask: {
modalVisible: boolean;
taskId: number | null;
};
gettingQuery: TasksQuery;
count: number;
current: Task[];
......@@ -245,6 +249,7 @@ export interface NotificationsState {
exporting: null | ErrorState;
deleting: null | ErrorState;
creating: null | ErrorState;
moving: null | ErrorState;
};
formats: {
fetching: null | ErrorState;
......@@ -310,6 +315,7 @@ export interface NotificationsState {
messages: {
tasks: {
loadingDone: string;
movingDone: string;
};
models: {
inferenceDone: string;
......
......@@ -45,6 +45,7 @@ const defaultState: NotificationsState = {
exporting: null,
deleting: null,
creating: null,
moving: null,
},
formats: {
fetching: null,
......@@ -110,6 +111,7 @@ const defaultState: NotificationsState = {
messages: {
tasks: {
loadingDone: '',
movingDone: '',
},
models: {
inferenceDone: '',
......@@ -387,6 +389,24 @@ export default function (state = defaultState, action: AnyAction): Notifications
},
};
}
case TasksActionTypes.MOVE_TASK_TO_PROJECT_FAILED: {
const taskID = action.payload.task.id;
return {
...state,
errors: {
...state.errors,
tasks: {
...state.errors.tasks,
moving: {
message:
'Could not move the' +
`<a href="/tasks/${taskID}" target="_blank">task ${taskID}</a> to a project`,
reason: action.payload.error.toString(),
},
},
},
};
}
case TasksActionTypes.DUMP_ANNOTATIONS_FAILED: {
const taskID = action.payload.task.id;
return {
......@@ -440,6 +460,20 @@ export default function (state = defaultState, action: AnyAction): Notifications
},
};
}
case TasksActionTypes.MOVE_TASK_TO_PROJECT_SUCCESS: {
const { id: taskId, projectId } = action.payload.task;
return {
...state,
messages: {
...state.messages,
tasks: {
...state.messages.tasks,
movingDone: `The task #${taskId} has been successfully moved to the project #${projectId}`,
},
},
};
}
case ProjectsActionTypes.GET_PROJECTS_FAILED: {
return {
...state,
......
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -14,6 +14,10 @@ const defaultState: TasksState = {
fetching: false,
updating: false,
hideEmpty: false,
moveTask: {
modalVisible: false,
taskId: null,
},
count: 0,
current: [],
gettingQuery: {
......@@ -351,6 +355,16 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState
hideEmpty: action.payload.hideEmpty,
};
}
case TasksActionTypes.SWITCH_MOVE_TASK_MODAL_VISIBLE: {
return {
...state,
moveTask: {
...state.moveTask,
modalVisible: action.payload.visible,
taskId: action.payload.taskId,
},
};
}
case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: {
return { ...defaultState };
......
......@@ -405,17 +405,76 @@ class TaskSerializer(WriteOnceMixin, serializers.ModelSerializer):
instance.bug_tracker)
instance.subset = validated_data.get('subset', instance.subset)
labels = validated_data.get('label_set', [])
for label in labels:
LabelSerializer.update_instance(label, instance)
if instance.project_id is None:
for label in labels:
LabelSerializer.update_instance(label, instance)
validated_project_id = validated_data.get('project_id', None)
if validated_project_id is not None and validated_project_id != instance.project_id:
project = models.Project.objects.get(id=validated_data.get('project_id', None))
if instance.project_id is None:
for old_label in instance.label_set.all():
try:
new_label = project.label_set.filter(name=old_label.name).first()
except ValueError:
raise serializers.ValidationError(f'Target project does not have label with name "{old_label.name}"')
old_label.attributespec_set.all().delete()
for model in (models.LabeledTrack, models.LabeledShape, models.LabeledImage):
model.objects.filter(job__segment__task=instance, label=old_label).update(
label=new_label
)
instance.label_set.all().delete()
else:
for old_label in instance.project.label_set.all():
if new_label_for_name := list(filter(lambda x: x.get('id', None) == old_label.id, labels)):
old_label.name = new_label_for_name[0].get('name', old_label.name)
try:
new_label = project.label_set.filter(name=old_label.name).first()
except ValueError:
raise serializers.ValidationError(f'Target project does not have label with name "{old_label.name}"')
for (model, attr, attr_name) in (
(models.LabeledTrack, models.LabeledTrackAttributeVal, 'track'),
(models.LabeledShape, models.LabeledShapeAttributeVal, 'shape'),
(models.LabeledImage, models.LabeledImageAttributeVal, 'image')
):
attr.objects.filter(**{
f'{attr_name}__job__segment__task': instance,
f'{attr_name}__label': old_label
}).delete()
model.objects.filter(job__segment__task=instance, label=old_label).update(
label=new_label
)
instance.project = project
instance.save()
return instance
def validate_labels(self, 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 task')
return value
def validate(self, attrs):
# When moving task labels can be mapped to one, but when not names must be unique
if 'project_id' in attrs.keys() and self.instance is not None:
project_id = attrs.get('project_id')
if project_id is not None and not models.Project.objects.filter(id=project_id).count():
raise serializers.ValidationError(f'Cannot find project with ID {project_id}')
# Check that all labels can be mapped
new_label_names = set()
old_labels = self.instance.project.label_set.all() if self.instance.project_id else self.instance.label_set.all()
for old_label in old_labels:
if len(new_labels := tuple(filter(lambda x: x.get('id') == old_label.id, attrs.get('label_set', [])))):
new_label_names.add(new_labels[0].get('name', old_label.name))
else:
new_label_names.add(old_label.name)
target_project = models.Project.objects.get(id=project_id)
target_project_label_names = set()
for label in target_project.label_set.all():
target_project_label_names.add(label.name)
if not new_label_names.issubset(target_project_label_names):
raise serializers.ValidationError('All task or project label names must be mapped to the target project')
else:
if 'label_set' in attrs.keys():
label_names = [label['name'] for label in attrs.get('label_set')]
if len(label_names) != len(set(label_names)):
raise serializers.ValidationError('All label names must be unique for the task')
return attrs
class ProjectSearchSerializer(serializers.ModelSerializer):
......
......@@ -1776,6 +1776,190 @@ class TaskUpdateLabelsAPITestCase(UpdateLabelsAPITestCase):
}
self._check_api_v1_task(data)
class TaskMoveAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
self._run_api_v1_job_id_annotation(self.task.segment_set.first().job_set.first().id, self.annotation_data)
@classmethod
def setUpTestData(cls):
create_db_users(cls)
projects = []
project_data = {
"name": "Project for task move 1",
"owner": cls.admin,
"labels": [{
"name": "car"
}, {
"name": "person"
}]
}
db_project = create_db_project(project_data)
projects.append(db_project)
project_data = {
"name": "Project for task move 2",
"owner": cls.admin,
"labels": [{
"name": "car",
"attributes": [{
"name": "color",
"mutable": False,
"input_type": AttributeType.SELECT,
"default_value": "white",
"values": ["white", "yellow", "green", "red"]
}]
}, {
"name": "test"
}, {
"name": "other.label"
}]
}
db_project = create_db_project(project_data)
projects.append(db_project)
cls.projects = projects
task_data = {
"name": "Task for moving",
"owner": cls.admin,
"overlap": 0,
"segment_size": 100,
"image_quality": 75,
"size": 100,
"project": None,
"labels": [{
"name": "car",
"attributes": [{
"name": "color",
"mutable": False,
"input_type": AttributeType.SELECT,
"default_value": "white",
"values": ["white", "yellow", "green", "red"]
}]
}]
}
db_task = create_db_task(task_data)
cls.task = db_task
cls.annotation_data = {
"version": 1,
"tags": [
{
"frame": 0,
"label_id": cls.task.label_set.first().id,
"group": None,
"source": "manual",
"attributes": []
}
],
"shapes": [
{
"frame": 0,
"label_id": cls.task.label_set.first().id,
"group": None,
"source": "manual",
"attributes": [
{
"spec_id": cls.task.label_set.first().attributespec_set.first().id,
"value": cls.task.label_set.first().attributespec_set.first().values.split('\'')[1]
}
],
"points": [1.0, 2.1, 100, 300.222],
"type": "rectangle",
"occluded": False
}
],
"tracks": [
{
"frame": 0,
"label_id": cls.task.label_set.first().id,
"group": None,
"source": "manual",
"attributes": [
{
"spec_id": cls.task.label_set.first().attributespec_set.first().id,
"value": cls.task.label_set.first().attributespec_set.first().values.split('\'')[1]
}
],
"shapes": [
{
"frame": 0,
"attributes": [],
"points": [1.0, 2.1, 100, 300.222],
"type": "rectangle",
"occluded": False,
"outside": False
},
{
"frame": 2,
"attributes": [],
"points": [2.0, 2.1, 100, 300.222],
"type": "rectangle",
"occluded": True,
"outside": True
},
]
}
]
}
def _run_api_v1_tasks_id(self, tid, data):
with ForceLogin(self.admin, self.client):
response = self.client.patch('/api/v1/tasks/{}'.format(tid),
data=data, format="json")
return response
def _run_api_v1_job_id_annotation(self, jid, data):
with ForceLogin(self.admin, self.client):
response = self.client.patch('/api/v1/jobs/{}/annotations?action=create'.format(jid),
data=data, format="json")
return response
def _check_response(self, response, data):
self.assertEqual(response.data["project_id"], data["project_id"])
def _check_api_v1_tasks(self, tid, data, expected_status=status.HTTP_200_OK):
response = self._run_api_v1_tasks_id(tid, data)
self.assertEqual(response.status_code, expected_status)
if (expected_status == status.HTTP_200_OK):
self._check_response(response, data)
def test_move_task_bad_request(self):
# Try to move task without proper label mapping
data = {
"project_id": self.projects[0].id,
"labels": [{
"id": self.task.label_set.first().id,
"name": "some.other.label"
}]
}
self._check_api_v1_tasks(self.task.id, data, status.HTTP_400_BAD_REQUEST)
def test_move_task(self):
# Try to move single task to the project
data = {
"project_id": self.projects[0].id
}
self._check_api_v1_tasks(self.task.id, data)
# Try to move task from project to the other project
data = {
"project_id": self.projects[1].id,
"labels": [{
"id": self.projects[0].label_set.all()[1].id,
"name": "test"
}]
}
self._check_api_v1_tasks(self.task.id, data)
class TaskCreateAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册