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

Label deleting (#2881)

* Added label deleting

* Added label deletion for server mock cvat-core tests

* vscode settings adjustments

* Added server tests

* Removed unused import

* Added CHANGELOG and increased npm version

* Added ingoring npm scripts for non-project directories

* Added dummy no labels wrapper

* Added handling no labels jobs

* Fixed PR comments

* Added generic usage to the hook
Co-authored-by: NBoris Sekachev <boris.sekachev@intel.com>
上级 5b46b516
{
"python.pythonPath": ".env/bin/python",
"eslint.enable": true,
"eslint.probe": [
"javascript",
"typescript",
......@@ -19,8 +18,10 @@
"!cwd": true
}
],
"npm.exclude": "**/.env/**",
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"python.testing.unittestEnabled": true,
"python.linting.pycodestyleEnabled": false,
"licenser.license": "Custom",
"licenser.customHeader": "Copyright (C) @YEAR@ Intel Corporation\n\nSPDX-License-Identifier: MIT",
"files.trimTrailingWhitespace": true
......
......@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [WiderFace](http://shuoyang1213.me/WIDERFACE/) format support (<https://github.com/openvinotoolkit/cvat/pull/2864>)
- [VGGFace2](https://github.com/ox-vgg/vgg_face2) format support (<https://github.com/openvinotoolkit/cvat/pull/2865>)
- [Backup/Restore guide](cvat/apps/documentation/backup_guide.md) (<https://github.com/openvinotoolkit/cvat/pull/2964>)
- Label deletion from tasks and projects (<https://github.com/openvinotoolkit/cvat/pull/2881>)
### Changed
......
......@@ -133,6 +133,7 @@
id: undefined,
name: undefined,
color: undefined,
deleted: false,
};
for (const key in data) {
......@@ -208,6 +209,12 @@
attributes: {
get: () => [...data.attributes],
},
deleted: {
get: () => data.deleted,
set: (value) => {
data.deleted = value;
},
},
}),
);
}
......@@ -223,6 +230,10 @@
object.id = this.id;
}
if (this.deleted) {
object.deleted = this.deleted;
}
return object;
}
}
......
......@@ -186,7 +186,13 @@
);
}
data.labels = [...labels];
const IDs = labels.map((_label) => _label.id);
const deletedLabels = data.labels.filter((_label) => !IDs.includes(_label.id));
deletedLabels.forEach((_label) => {
_label.deleted = true;
});
data.labels = [...deletedLabels, ...labels];
},
},
/**
......@@ -211,6 +217,9 @@
subsets: {
get: () => [...data.task_subsets],
},
_internalData: {
get: () => data,
},
}),
);
}
......@@ -257,7 +266,7 @@
name: this.name,
assignee_id: this.assignee ? this.assignee.id : null,
bug_tracker: this.bugTracker,
labels: [...this.labels.map((el) => el.toJSON())],
labels: [...this._internalData.labels.map((el) => el.toJSON())],
};
await serverProxy.projects.save(this.id, projectData);
......
......@@ -1304,7 +1304,7 @@
* @throws {module:API.cvat.exceptions.ArgumentError}
*/
labels: {
get: () => [...data.labels],
get: () => data.labels.filter((_label) => !_label.deleted),
set: (labels) => {
if (!Array.isArray(labels)) {
throw new ArgumentError('Value must be an array of Labels');
......@@ -1318,8 +1318,14 @@
}
}
const IDs = labels.map((_label) => _label.id);
const deletedLabels = data.labels.filter((_label) => !IDs.includes(_label.id));
deletedLabels.forEach((_label) => {
_label.deleted = true;
});
updatedFields.labels = true;
data.labels = [...labels];
data.labels = [...deletedLabels, ...labels];
},
},
/**
......@@ -1485,12 +1491,6 @@
dataChunkType: {
get: () => data.data_compressed_chunk_type,
},
__updatedFields: {
get: () => updatedFields,
set: (fields) => {
updatedFields = fields;
},
},
dimension: {
/**
* @name enabled
......@@ -1501,6 +1501,15 @@
*/
get: () => data.dimension,
},
_internalData: {
get: () => data,
},
__updatedFields: {
get: () => updatedFields,
set: (fields) => {
updatedFields = fields;
},
},
}),
);
......@@ -1920,7 +1929,7 @@
taskData.subset = this.subset;
break;
case 'labels':
taskData.labels = [...this.labels.map((el) => el.toJSON())];
taskData.labels = [...this._internalData.labels.map((el) => el.toJSON())];
break;
default:
break;
......
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -97,7 +97,11 @@ class ServerProxy {
Object.prototype.hasOwnProperty.call(projectData, prop)
&& Object.prototype.hasOwnProperty.call(object, prop)
) {
object[prop] = projectData[prop];
if (prop === 'labels') {
object[prop] = projectData[prop].filter((label) => !label.deleted);
} else {
object[prop] = projectData[prop];
}
}
}
}
......@@ -156,7 +160,11 @@ class ServerProxy {
Object.prototype.hasOwnProperty.call(taskData, prop)
&& Object.prototype.hasOwnProperty.call(object, prop)
) {
object[prop] = taskData[prop];
if (prop === 'labels') {
object[prop] = taskData[prop].filter((label) => !label.deleted);
} else {
object[prop] = taskData[prop];
}
}
}
}
......
......@@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: MIT
import { MutableRefObject } from 'react';
import {
AnyAction, Dispatch, ActionCreator, Store,
} from 'redux';
......@@ -26,7 +27,6 @@ import getCore from 'cvat-core-wrapper';
import logger, { LogType } from 'cvat-logger';
import { RectDrawingMethod } from 'cvat-canvas-wrapper';
import { getCVATStore } from 'cvat-store';
import { MutableRefObject } from 'react';
interface AnnotationsParameters {
filters: string[];
......@@ -919,10 +919,6 @@ 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
......
......@@ -8,8 +8,10 @@ import { useHistory } from 'react-router';
import Layout from 'antd/lib/layout';
import Spin from 'antd/lib/spin';
import Result from 'antd/lib/result';
import notification from 'antd/lib/notification';
import { Workspace } from 'reducers/interfaces';
import { usePrevious } from 'utils/hooks';
import AnnotationTopBarContainer from 'containers/annotation-page/top-bar/top-bar';
import StatisticsModalContainer from 'containers/annotation-page/top-bar/statistics-modal';
import StandardWorkspaceComponent from 'components/annotation-page/standard-workspace/standard-workspace';
......@@ -33,6 +35,8 @@ export default function AnnotationPageComponent(props: Props): JSX.Element {
const {
job, fetching, getJob, closeJob, saveLogs, workspace,
} = props;
const prevJob = usePrevious(job);
const prevFetching = usePrevious(fetching);
const history = useHistory();
useEffect(() => {
......@@ -60,6 +64,26 @@ export default function AnnotationPageComponent(props: Props): JSX.Element {
}
}, [job, fetching]);
useEffect(() => {
if (prevFetching && !fetching && !prevJob && job && !job.task.labels.length) {
notification.warning({
message: 'No labels',
description: (
<span>
{`${job.task.projectId ? 'Project' : 'Task'} ${
job.task.projectId || job.task.id
} does not contain any label. `}
<a href={`/${job.task.projectId ? 'projects' : 'tasks'}/${job.task.projectId || job.task.id}/`}>
Add
</a>
{' the first one for editing annotation.'}
</span>
),
placement: 'topRight',
});
}
}, [job, fetching, prevJob, prevFetching]);
if (job === null) {
return <Spin size='large' className='cvat-spinner' />;
}
......
......@@ -3,8 +3,8 @@
// SPDX-License-Identifier: MIT
import React from 'react';
import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react';
import Layout from 'antd/lib/layout';
import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react';
import { ActiveControl, Rotation } from 'reducers/interfaces';
import { Canvas } from 'cvat-canvas-wrapper';
......@@ -32,6 +32,7 @@ interface Props {
activeControl: ActiveControl;
keyMap: KeyMap;
normalizedKeyMap: Record<string, string>;
labels: any[];
mergeObjects(enabled: boolean): void;
groupObjects(enabled: boolean): void;
......@@ -64,10 +65,11 @@ const ObservedSplitControl = ControlVisibilityObserver<SplitControlProps>(SplitC
export default function ControlsSideBarComponent(props: Props): JSX.Element {
const {
canvasInstance,
activeControl,
canvasInstance,
normalizedKeyMap,
keyMap,
labels,
mergeObjects,
groupObjects,
splitTrack,
......@@ -84,93 +86,13 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
}
};
const subKeyMap = {
PASTE_SHAPE: keyMap.PASTE_SHAPE,
SWITCH_DRAW_MODE: keyMap.SWITCH_DRAW_MODE,
SWITCH_MERGE_MODE: keyMap.SWITCH_MERGE_MODE,
SWITCH_SPLIT_MODE: keyMap.SWITCH_SPLIT_MODE,
SWITCH_GROUP_MODE: keyMap.SWITCH_GROUP_MODE,
RESET_GROUP: keyMap.RESET_GROUP,
let subKeyMap: any = {
CANCEL: keyMap.CANCEL,
CLOCKWISE_ROTATION: keyMap.CLOCKWISE_ROTATION,
ANTICLOCKWISE_ROTATION: keyMap.ANTICLOCKWISE_ROTATION,
};
const handlers = {
PASTE_SHAPE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
canvasInstance.cancel();
pasteShape();
},
SWITCH_DRAW_MODE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const drawing = [
ActiveControl.DRAW_POINTS,
ActiveControl.DRAW_POLYGON,
ActiveControl.DRAW_POLYLINE,
ActiveControl.DRAW_RECTANGLE,
ActiveControl.DRAW_CUBOID,
ActiveControl.AI_TOOLS,
ActiveControl.OPENCV_TOOLS,
].includes(activeControl);
if (!drawing) {
canvasInstance.cancel();
// repeateDrawShapes gets all the latest parameters
// and calls canvasInstance.draw() with them
if (event && event.shiftKey) {
redrawShape();
} else {
repeatDrawShape();
}
} else {
if ([ActiveControl.AI_TOOLS, ActiveControl.OPENCV_TOOLS].includes(activeControl)) {
// separated API method
canvasInstance.interact({ enabled: false });
return;
}
canvasInstance.draw({ enabled: false });
}
},
SWITCH_MERGE_MODE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const merging = activeControl === ActiveControl.MERGE;
if (!merging) {
canvasInstance.cancel();
}
canvasInstance.merge({ enabled: !merging });
mergeObjects(!merging);
},
SWITCH_SPLIT_MODE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const splitting = activeControl === ActiveControl.SPLIT;
if (!splitting) {
canvasInstance.cancel();
}
canvasInstance.split({ enabled: !splitting });
splitTrack(!splitting);
},
SWITCH_GROUP_MODE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const grouping = activeControl === ActiveControl.GROUP;
if (!grouping) {
canvasInstance.cancel();
}
canvasInstance.group({ enabled: !grouping });
groupObjects(!grouping);
},
RESET_GROUP: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const grouping = activeControl === ActiveControl.GROUP;
if (!grouping) {
return;
}
resetGroup();
canvasInstance.group({ enabled: false });
groupObjects(false);
},
let handlers: any = {
CANCEL: (event: KeyboardEvent | undefined) => {
preventDefault(event);
if (activeControl !== ActiveControl.CURSOR) {
......@@ -187,6 +109,95 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
},
};
if (labels.length) {
handlers = {
...handlers,
PASTE_SHAPE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
canvasInstance.cancel();
pasteShape();
},
SWITCH_DRAW_MODE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const drawing = [
ActiveControl.DRAW_POINTS,
ActiveControl.DRAW_POLYGON,
ActiveControl.DRAW_POLYLINE,
ActiveControl.DRAW_RECTANGLE,
ActiveControl.DRAW_CUBOID,
ActiveControl.AI_TOOLS,
ActiveControl.OPENCV_TOOLS,
].includes(activeControl);
if (!drawing) {
canvasInstance.cancel();
// repeateDrawShapes gets all the latest parameters
// and calls canvasInstance.draw() with them
if (event && event.shiftKey) {
redrawShape();
} else {
repeatDrawShape();
}
} else {
if ([ActiveControl.AI_TOOLS, ActiveControl.OPENCV_TOOLS].includes(activeControl)) {
// separated API method
canvasInstance.interact({ enabled: false });
return;
}
canvasInstance.draw({ enabled: false });
}
},
SWITCH_MERGE_MODE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const merging = activeControl === ActiveControl.MERGE;
if (!merging) {
canvasInstance.cancel();
}
canvasInstance.merge({ enabled: !merging });
mergeObjects(!merging);
},
SWITCH_SPLIT_MODE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const splitting = activeControl === ActiveControl.SPLIT;
if (!splitting) {
canvasInstance.cancel();
}
canvasInstance.split({ enabled: !splitting });
splitTrack(!splitting);
},
SWITCH_GROUP_MODE: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const grouping = activeControl === ActiveControl.GROUP;
if (!grouping) {
canvasInstance.cancel();
}
canvasInstance.group({ enabled: !grouping });
groupObjects(!grouping);
},
RESET_GROUP: (event: KeyboardEvent | undefined) => {
preventDefault(event);
const grouping = activeControl === ActiveControl.GROUP;
if (!grouping) {
return;
}
resetGroup();
canvasInstance.group({ enabled: false });
groupObjects(false);
},
};
subKeyMap = {
...subKeyMap,
PASTE_SHAPE: keyMap.PASTE_SHAPE,
SWITCH_DRAW_MODE: keyMap.SWITCH_DRAW_MODE,
SWITCH_MERGE_MODE: keyMap.SWITCH_MERGE_MODE,
SWITCH_SPLIT_MODE: keyMap.SWITCH_SPLIT_MODE,
SWITCH_GROUP_MODE: keyMap.SWITCH_GROUP_MODE,
RESET_GROUP: keyMap.RESET_GROUP,
};
}
return (
<Layout.Sider className='cvat-canvas-controls-sidebar' theme='light' width={44}>
<GlobalHotKeys keyMap={subKeyMap} handlers={handlers} />
......@@ -213,24 +224,29 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
<ObservedDrawRectangleControl
canvasInstance={canvasInstance}
isDrawing={activeControl === ActiveControl.DRAW_RECTANGLE}
disabled={!labels.length}
/>
<ObservedDrawPolygonControl
canvasInstance={canvasInstance}
isDrawing={activeControl === ActiveControl.DRAW_POLYGON}
disabled={!labels.length}
/>
<ObservedDrawPolylineControl
canvasInstance={canvasInstance}
isDrawing={activeControl === ActiveControl.DRAW_POLYLINE}
disabled={!labels.length}
/>
<ObservedDrawPointsControl
canvasInstance={canvasInstance}
isDrawing={activeControl === ActiveControl.DRAW_POINTS}
disabled={!labels.length}
/>
<ObservedDrawCuboidControl
canvasInstance={canvasInstance}
isDrawing={activeControl === ActiveControl.DRAW_CUBOID}
disabled={!labels.length}
/>
<ObservedSetupTagControl canvasInstance={canvasInstance} isDrawing={false} />
<ObservedSetupTagControl canvasInstance={canvasInstance} isDrawing={false} disabled={!labels.length} />
<hr />
......@@ -239,6 +255,7 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
canvasInstance={canvasInstance}
activeControl={activeControl}
mergeObjects={mergeObjects}
disabled={!labels.length}
/>
<ObservedGroupControl
switchGroupShortcut={normalizedKeyMap.SWITCH_GROUP_MODE}
......@@ -246,12 +263,14 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
canvasInstance={canvasInstance}
activeControl={activeControl}
groupObjects={groupObjects}
disabled={!labels.length}
/>
<ObservedSplitControl
canvasInstance={canvasInstance}
switchSplitShortcut={normalizedKeyMap.SWITCH_SPLIT_MODE}
activeControl={activeControl}
splitTrack={splitTrack}
disabled={!labels.length}
/>
<ExtraControlsControl />
......
......@@ -17,11 +17,12 @@ import withVisibilityHandling from './handle-popover-visibility';
export interface Props {
canvasInstance: Canvas;
isDrawing: boolean;
disabled?: boolean;
}
const CustomPopover = withVisibilityHandling(Popover, 'draw-cuboid');
function DrawPolygonControl(props: Props): JSX.Element {
const { canvasInstance, isDrawing } = props;
const { canvasInstance, isDrawing, disabled } = props;
const dynamcPopoverPros = isDrawing ?
{
overlayStyle: {
......@@ -41,7 +42,9 @@ function DrawPolygonControl(props: Props): JSX.Element {
className: 'cvat-draw-cuboid-control',
};
return (
return disabled ? (
<Icon className='cvat-draw-cuboid-control cvat-disabled-canvas-control' component={CubeIcon} />
) : (
<CustomPopover
{...dynamcPopoverPros}
overlayClassName='cvat-draw-shape-popover'
......
......@@ -16,11 +16,12 @@ import withVisibilityHandling from './handle-popover-visibility';
export interface Props {
canvasInstance: Canvas;
isDrawing: boolean;
disabled?: boolean;
}
const CustomPopover = withVisibilityHandling(Popover, 'draw-points');
function DrawPointsControl(props: Props): JSX.Element {
const { canvasInstance, isDrawing } = props;
const { canvasInstance, isDrawing, disabled } = props;
const dynamcPopoverPros = isDrawing ?
{
overlayStyle: {
......@@ -40,7 +41,9 @@ function DrawPointsControl(props: Props): JSX.Element {
className: 'cvat-draw-points-control',
};
return (
return disabled ? (
<Icon className='cvat-draw-points-control cvat-disabled-canvas-control' component={PointIcon} />
) : (
<CustomPopover
{...dynamcPopoverPros}
overlayClassName='cvat-draw-shape-popover'
......
......@@ -16,11 +16,12 @@ import withVisibilityHandling from './handle-popover-visibility';
export interface Props {
canvasInstance: Canvas;
isDrawing: boolean;
disabled?: boolean;
}
const CustomPopover = withVisibilityHandling(Popover, 'draw-polygon');
function DrawPolygonControl(props: Props): JSX.Element {
const { canvasInstance, isDrawing } = props;
const { canvasInstance, isDrawing, disabled } = props;
const dynamcPopoverPros = isDrawing ?
{
overlayStyle: {
......@@ -40,7 +41,9 @@ function DrawPolygonControl(props: Props): JSX.Element {
className: 'cvat-draw-polygon-control',
};
return (
return disabled ? (
<Icon className='cvat-draw-polygon-control cvat-disabled-canvas-control' component={PolygonIcon} />
) : (
<CustomPopover
{...dynamcPopoverPros}
overlayClassName='cvat-draw-shape-popover'
......
......@@ -16,11 +16,12 @@ import withVisibilityHandling from './handle-popover-visibility';
export interface Props {
canvasInstance: Canvas;
isDrawing: boolean;
disabled?: boolean;
}
const CustomPopover = withVisibilityHandling(Popover, 'draw-polyline');
function DrawPolylineControl(props: Props): JSX.Element {
const { canvasInstance, isDrawing } = props;
const { canvasInstance, isDrawing, disabled } = props;
const dynamcPopoverPros = isDrawing ?
{
overlayStyle: {
......@@ -40,7 +41,9 @@ function DrawPolylineControl(props: Props): JSX.Element {
className: 'cvat-draw-polyline-control',
};
return (
return disabled ? (
<Icon className='cvat-draw-polyline-control cvat-disabled-canvas-control' component={PolylineIcon} />
) : (
<CustomPopover
{...dynamcPopoverPros}
overlayClassName='cvat-draw-shape-popover'
......
......@@ -16,11 +16,12 @@ import withVisibilityHandling from './handle-popover-visibility';
export interface Props {
canvasInstance: Canvas;
isDrawing: boolean;
disabled?: boolean;
}
const CustomPopover = withVisibilityHandling(Popover, 'draw-rectangle');
function DrawRectangleControl(props: Props): JSX.Element {
const { canvasInstance, isDrawing } = props;
const { canvasInstance, isDrawing, disabled } = props;
const dynamcPopoverPros = isDrawing ?
{
overlayStyle: {
......@@ -40,7 +41,9 @@ function DrawRectangleControl(props: Props): JSX.Element {
className: 'cvat-draw-rectangle-control',
};
return (
return disabled ? (
<Icon className='cvat-draw-rectangle-control cvat-disabled-canvas-control' component={RectangleIcon} />
) : (
<CustomPopover
{...dynamcPopoverPros}
overlayClassName='cvat-draw-shape-popover'
......
......@@ -15,12 +15,13 @@ export interface Props {
activeControl: ActiveControl;
switchGroupShortcut: string;
resetGroupShortcut: string;
disabled?: boolean;
groupObjects(enabled: boolean): void;
}
function GroupControl(props: Props): JSX.Element {
const {
switchGroupShortcut, resetGroupShortcut, activeControl, canvasInstance, groupObjects,
switchGroupShortcut, resetGroupShortcut, activeControl, canvasInstance, groupObjects, disabled,
} = props;
const dynamicIconProps =
......@@ -46,7 +47,9 @@ function GroupControl(props: Props): JSX.Element {
`Select and press ${resetGroupShortcut} to reset a group.`,
].join(' ');
return (
return disabled ? (
<Icon className='cvat-group-control cvat-disabled-canvas-control' component={GroupIcon} />
) : (
<CVATTooltip title={title} placement='right'>
<Icon {...dynamicIconProps} component={GroupIcon} />
</CVATTooltip>
......
......@@ -14,12 +14,13 @@ export interface Props {
canvasInstance: Canvas;
activeControl: ActiveControl;
switchMergeShortcut: string;
disabled?: boolean;
mergeObjects(enabled: boolean): void;
}
function MergeControl(props: Props): JSX.Element {
const {
switchMergeShortcut, activeControl, canvasInstance, mergeObjects,
switchMergeShortcut, activeControl, canvasInstance, mergeObjects, disabled,
} = props;
const dynamicIconProps =
......@@ -40,7 +41,9 @@ function MergeControl(props: Props): JSX.Element {
},
};
return (
return disabled ? (
<Icon className='cvat-merge-control cvat-disabled-canvas-control' component={MergeIcon} />
) : (
<CVATTooltip title={`Merge shapes/tracks ${switchMergeShortcut}`} placement='right'>
<Icon {...dynamicIconProps} component={MergeIcon} />
</CVATTooltip>
......
......@@ -108,7 +108,7 @@ class OpenCVControlComponent extends React.PureComponent<Props & DispatchToProps
libraryInitialized: openCVWrapper.isInitialized,
initializationError: false,
initializationProgress: -1,
activeLabelID: labels[0].id,
activeLabelID: labels.length ? labels[0].id : null,
};
}
......@@ -383,7 +383,7 @@ class OpenCVControlComponent extends React.PureComponent<Props & DispatchToProps
}
public render(): JSX.Element {
const { isActivated, canvasInstance } = this.props;
const { isActivated, canvasInstance, labels } = this.props;
const dynamcPopoverPros = isActivated ?
{
overlayStyle: {
......@@ -394,7 +394,7 @@ class OpenCVControlComponent extends React.PureComponent<Props & DispatchToProps
const dynamicIconProps = isActivated ?
{
className: 'cvat-active-canvas-control cvat-opencv-control',
className: 'cvat-opencv-control cvat-active-canvas-control',
onClick: (): void => {
canvasInstance.interact({ enabled: false });
},
......@@ -403,7 +403,9 @@ class OpenCVControlComponent extends React.PureComponent<Props & DispatchToProps
className: 'cvat-tools-control',
};
return (
return !labels.length ? (
<Icon className='cvat-opencv-control cvat-disabled-canvas-control' component={OpenCVIcon} />
) : (
<CustomPopover
{...dynamcPopoverPros}
placement='right'
......
......@@ -15,11 +15,12 @@ import withVisibilityHandling from './handle-popover-visibility';
export interface Props {
canvasInstance: Canvas;
isDrawing: boolean;
disabled?: boolean;
}
const CustomPopover = withVisibilityHandling(Popover, 'setup-tag');
function SetupTagControl(props: Props): JSX.Element {
const { isDrawing } = props;
const { isDrawing, disabled } = props;
const dynamcPopoverPros = isDrawing ?
{
overlayStyle: {
......@@ -28,7 +29,9 @@ function SetupTagControl(props: Props): JSX.Element {
} :
{};
return (
return disabled ? (
<Icon className='cvat-setup-tag-control cvat-disabled-canvas-control' component={TagIcon} />
) : (
<CustomPopover {...dynamcPopoverPros} placement='right' content={<SetupTagPopoverContainer />}>
<Icon className='cvat-setup-tag-control' component={TagIcon} />
</CustomPopover>
......
......@@ -14,12 +14,13 @@ export interface Props {
canvasInstance: Canvas;
activeControl: ActiveControl;
switchSplitShortcut: string;
disabled?: boolean;
splitTrack(enabled: boolean): void;
}
function SplitControl(props: Props): JSX.Element {
const {
switchSplitShortcut, activeControl, canvasInstance, splitTrack,
switchSplitShortcut, activeControl, canvasInstance, splitTrack, disabled,
} = props;
const dynamicIconProps =
......@@ -40,7 +41,9 @@ function SplitControl(props: Props): JSX.Element {
},
};
return (
return disabled ? (
<Icon className='cvat-split-track-control cvat-disabled-canvas-control' component={SplitIcon} />
) : (
<CVATTooltip title={`Split a track ${switchSplitShortcut}`} placement='right'>
<Icon {...dynamicIconProps} component={SplitIcon} />
</CVATTooltip>
......
......@@ -111,7 +111,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
this.state = {
activeInteractor: props.interactors.length ? props.interactors[0] : null,
activeTracker: props.trackers.length ? props.trackers[0] : null,
activeLabelID: props.labels[0].id,
activeLabelID: props.labels.length ? props.labels[0].id : null,
interactiveStateID: null,
trackingProgress: null,
trackingFrames: 10,
......@@ -239,7 +239,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
const object = new core.classes.ObjectState({
frame,
objectType: ObjectType.SHAPE,
label: labels.filter((label: any) => label.id === activeLabelID)[0],
label: labels.length ? labels.filter((label: any) => label.id === activeLabelID)[0] : null,
shapeType: ShapeType.POLYGON,
points: result.flat(),
occluded: false,
......@@ -257,7 +257,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
const object = new core.classes.ObjectState({
frame,
objectType: ObjectType.SHAPE,
label: labels.filter((label: any) => label.id === activeLabelID)[0],
label: labels.length ? labels.filter((label: any) => label.id === activeLabelID)[0] : null,
shapeType: ShapeType.POLYGON,
points: result.flat(),
occluded: false,
......@@ -716,7 +716,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
public render(): JSX.Element | null {
const {
interactors, detectors, trackers, isActivated, canvasInstance,
interactors, detectors, trackers, isActivated, canvasInstance, labels,
} = this.props;
const { fetching, trackingProgress } = this.state;
......@@ -732,7 +732,7 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
const dynamicIconProps = isActivated ?
{
className: 'cvat-active-canvas-control cvat-tools-control',
className: 'cvat-tools-control cvat-active-canvas-control',
onClick: (): void => {
canvasInstance.interact({ enabled: false });
},
......@@ -741,7 +741,9 @@ export class ToolsControlComponent extends React.PureComponent<Props, State> {
className: 'cvat-tools-control',
};
return (
return !labels.length ? (
<Icon className=' cvat-tools-control cvat-disabled-canvas-control' component={AIToolsIcon} />
) : (
<>
<Modal
title='Making a server request'
......
......@@ -61,12 +61,12 @@
transform: scale(0.65);
padding: 2px;
&:hover {
&:hover:not(.cvat-disabled-canvas-control) {
background: $header-color;
transform: scale(0.75);
}
&:active {
&:active:not(.cvat-disabled-canvas-control) {
transform: scale(0.65);
}
......@@ -87,6 +87,10 @@
transform: scale(0.75);
}
.cvat-disabled-canvas-control > svg {
filter: opacity(0.45);
}
.cvat-rotate-canvas-controls-left,
.cvat-rotate-canvas-controls-right {
transform: scale(0.65);
......
......@@ -45,3 +45,7 @@
padding: 5px 10px;
}
}
.labels-tag-annotation-sidebar-not-found-wrapper {
margin-top: $grid-unit-size * 4;
}
......@@ -4,7 +4,6 @@
import React, { useState, useEffect } from 'react';
import { connect } from 'react-redux';
import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react';
import { Action } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { Row, Col } from 'antd/lib/grid';
......@@ -21,6 +20,7 @@ import {
changeFrameAsync,
rememberObject,
} from 'actions/annotation-actions';
import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react';
import { Canvas } from 'cvat-canvas-wrapper';
import { CombinedState, ObjectType } from 'reducers/interfaces';
import LabelSelector from 'components/label-selector/label-selector';
......@@ -107,7 +107,7 @@ function TagAnnotationSidebar(props: StateToProps & DispatchToProps): JSX.Elemen
}
};
const defaultLabelID = labels[0].id;
const defaultLabelID = labels.length ? labels[0].id : null;
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [frameTags, setFrameTags] = useState([] as any[]);
......@@ -196,7 +196,24 @@ function TagAnnotationSidebar(props: StateToProps & DispatchToProps): JSX.Elemen
},
};
return (
return !labels.length ? (
<Layout.Sider {...siderProps}>
{/* eslint-disable-next-line */}
<span
className={`cvat-objects-sidebar-sider
ant-layout-sider-zero-width-trigger
ant-layout-sider-zero-width-trigger-left`}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
{sidebarCollapsed ? <MenuFoldOutlined title='Show' /> : <MenuUnfoldOutlined title='Hide' />}
</span>
<Row justify='center' className='labels-tag-annotation-sidebar-not-found-wrapper'>
<Col>
<Text strong>No labels are available.</Text>
</Col>
</Row>
</Layout.Sider>
) : (
<>
<GlobalHotKeys keyMap={subKeyMap} handlers={handlers} />
<Layout.Sider {...siderProps}>
......
......@@ -35,18 +35,16 @@ export default function ConstructorViewerItem(props: ConstructorViewerItemProps)
<EditOutlined />
</span>
</CVATTooltip>
{label.id < 0 && (
<CVATTooltip title='Delete label'>
<span
role='button'
tabIndex={0}
onClick={(): void => onDelete(label)}
onKeyPress={(): boolean => false}
>
<CloseOutlined />
</span>
</CVATTooltip>
)}
<CVATTooltip title='Delete label'>
<span
role='button'
tabIndex={0}
onClick={(): void => onDelete(label)}
onKeyPress={(): boolean => false}
>
<CloseOutlined />
</span>
</CVATTooltip>
</div>
);
}
......@@ -6,10 +6,12 @@ import './styles.scss';
import React from 'react';
import Tabs from 'antd/lib/tabs';
import Button from 'antd/lib/button';
import notification from 'antd/lib/notification';
import Text from 'antd/lib/typography/Text';
import ModalConfirm from 'antd/lib/modal/confirm';
import copy from 'copy-to-clipboard';
import { CopyOutlined, EditOutlined, BuildOutlined } from '@ant-design/icons';
import {
CopyOutlined, EditOutlined, BuildOutlined, ExclamationCircleOutlined,
} from '@ant-design/icons';
import CVATTooltip from 'components/common/cvat-tooltip';
import RawViewer from './raw-viewer';
......@@ -144,20 +146,30 @@ export default class LabelsEditor extends React.PureComponent<LabelsEditortProps
};
private handleDelete = (label: Label): void => {
// the label is saved on the server, cannot delete it
if (typeof label.id !== 'undefined' && label.id >= 0) {
notification.error({
message: 'Could not delete the label',
description: 'It has been already saved on the server',
});
}
const deleteLabel = (): void => {
const { unsavedLabels, savedLabels } = this.state;
const { unsavedLabels, savedLabels } = this.state;
const filteredUnsavedLabels = unsavedLabels.filter((_label: Label): boolean => _label.id !== label.id);
const filteredSavedLabels = savedLabels.filter((_label: Label): boolean => _label.id !== label.id);
const filteredUnsavedLabels = unsavedLabels.filter((_label: Label): boolean => _label.id !== label.id);
this.setState({ savedLabels: filteredSavedLabels, unsavedLabels: filteredUnsavedLabels });
this.handleSubmit(filteredSavedLabels, filteredUnsavedLabels);
};
this.setState({ unsavedLabels: filteredUnsavedLabels });
this.handleSubmit(savedLabels, filteredUnsavedLabels);
if (typeof label.id !== 'undefined' && label.id >= 0) {
ModalConfirm({
title: `Do you want to delete "${label.name}" label?`,
icon: <ExclamationCircleOutlined />,
content: 'This action is irreversible. Annotation corresponding with this label will be deleted.',
type: 'warning',
okType: 'danger',
onOk() {
deleteLabel();
},
});
} else {
deleteLabel();
}
};
private handleSubmit(savedLabels: Label[], unsavedLabels: Label[]): void {
......
......@@ -2,7 +2,6 @@
//
// SPDX-License-Identifier: MIT
import { KeyMap } from 'utils/mousetrap-react';
import { connect } from 'react-redux';
import { Canvas } from 'cvat-canvas-wrapper';
......@@ -18,6 +17,7 @@ import {
} from 'actions/annotation-actions';
import ControlsSideBarComponent from 'components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar';
import { ActiveControl, CombinedState, Rotation } from 'reducers/interfaces';
import { KeyMap } from 'utils/mousetrap-react';
interface StateToProps {
canvasInstance: Canvas;
......@@ -25,6 +25,7 @@ interface StateToProps {
activeControl: ActiveControl;
keyMap: KeyMap;
normalizedKeyMap: Record<string, string>;
labels: any[];
}
interface DispatchToProps {
......@@ -42,6 +43,7 @@ function mapStateToProps(state: CombinedState): StateToProps {
const {
annotation: {
canvas: { instance: canvasInstance, activeControl },
job: { labels },
},
settings: {
player: { rotateAll },
......@@ -53,6 +55,7 @@ function mapStateToProps(state: CombinedState): StateToProps {
rotateAll,
canvasInstance,
activeControl,
labels,
normalizedKeyMap,
keyMap,
};
......
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -79,7 +79,7 @@ class DrawShapePopoverContainer extends React.PureComponent<Props, State> {
super(props);
const { shapeType } = props;
const defaultLabelID = props.labels[0].id;
const defaultLabelID = props.labels.length ? props.labels[0].id : null;
const defaultRectDrawingMethod = RectDrawingMethod.CLASSIC;
const defaultCuboidDrawingMethod = CuboidDrawingMethod.CLASSIC;
this.state = {
......
......@@ -182,7 +182,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
drawing: {
...state.drawing,
activeLabelID: job.task.labels[0].id,
activeLabelID: job.task.labels.length ? job.task.labels[0].id : null,
activeObjectType: job.task.mode === 'interpolation' ? ObjectType.TRACK : ObjectType.SHAPE,
},
canvas: {
......
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { useRef, useEffect } from 'react';
// eslint-disable-next-line import/prefer-default-export
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
......@@ -70,10 +70,17 @@ class LabelSerializer(serializers.ModelSerializer):
attributes = AttributeSerializer(many=True, source='attributespec_set',
default=[])
color = serializers.CharField(allow_blank=True, required=False)
deleted = serializers.BooleanField(required=False)
class Meta:
model = models.Label
fields = ('id', 'name', 'color', 'attributes')
fields = ('id', 'name', 'color', 'attributes', 'deleted')
def validate(self, attrs):
if attrs.get('deleted') == True and attrs.get('id') is None:
raise serializers.ValidationError('Deleted label must have an ID')
return attrs
@staticmethod
def update_instance(validated_data, parent_instance):
......@@ -96,6 +103,9 @@ class LabelSerializer(serializers.ModelSerializer):
else:
db_label = models.Label.objects.create(name=validated_data.get('name'), **instance)
logger.info("New {} label was created".format(db_label.name))
if validated_data.get('deleted') == True:
db_label.delete()
return
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')
......
......@@ -28,7 +28,7 @@ from pycocotools import coco as coco_loader
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from cvat.apps.engine.models import (AttributeType, Data, Job, Project,
from cvat.apps.engine.models import (AttributeSpec, AttributeType, Data, Job, Project,
Segment, StatusChoice, Task, Label, StorageMethodChoice, StorageChoice)
from cvat.apps.engine.prepare import prepare_meta, prepare_meta_for_upload
from cvat.apps.engine.media_extractors import ValidateDimension
......@@ -72,6 +72,7 @@ def create_db_task(data):
os.makedirs(db_data.get_data_dirname())
os.makedirs(db_data.get_upload_dirname())
labels = data.pop('labels', None)
db_task = Task.objects.create(**data)
shutil.rmtree(db_task.get_task_dirname(), ignore_errors=True)
os.makedirs(db_task.get_task_dirname())
......@@ -80,6 +81,17 @@ def create_db_task(data):
db_task.data = db_data
db_task.save()
if not labels is None:
for label_data in labels:
attributes = label_data.pop('attributes', None)
db_label = Label(task=db_task, **label_data)
db_label.save()
if not attributes is None:
for attribute_data in attributes:
db_attribute = AttributeSpec(label=db_label, **attribute_data)
db_attribute.save()
for x in range(0, db_task.data.size, db_task.segment_size):
start_frame = x
stop_frame = min(x + db_task.segment_size - 1, db_task.data.size - 1)
......@@ -96,6 +108,26 @@ def create_db_task(data):
return db_task
def create_db_project(data):
labels = data.pop('labels', None)
db_project = Project.objects.create(**data)
shutil.rmtree(db_project.get_project_dirname(), ignore_errors=True)
os.makedirs(db_project.get_project_dirname())
os.makedirs(db_project.get_project_logs_dirname())
if not labels is None:
for label_data in labels:
attributes = label_data.pop('attributes', None)
db_label = Label(project=db_project, **label_data)
db_label.save()
if not attributes is None:
for attribute_data in attributes:
db_attribute = AttributeSpec(label=db_label, **attribute_data)
db_attribute.save()
return db_project
def create_dummy_db_tasks(obj, project=None):
tasks = []
......@@ -159,14 +191,14 @@ def create_dummy_db_projects(obj):
"owner": obj.owner,
"assignee": obj.assignee,
}
db_project = Project.objects.create(**data)
db_project = create_db_project(data)
projects.append(db_project)
data = {
"name": "my project without assignee",
"owner": obj.user,
}
db_project = Project.objects.create(**data)
db_project = create_db_project(data)
create_dummy_db_tasks(obj, db_project)
projects.append(db_project)
......@@ -175,14 +207,14 @@ def create_dummy_db_projects(obj):
"owner": obj.owner,
"assignee": obj.assignee,
}
db_project = Project.objects.create(**data)
db_project = create_db_project(data)
create_dummy_db_tasks(obj, db_project)
projects.append(db_project)
data = {
"name": "public project",
}
db_project = Project.objects.create(**data)
db_project = create_db_project(data)
create_dummy_db_tasks(obj, db_project)
projects.append(db_project)
......@@ -191,7 +223,7 @@ def create_dummy_db_projects(obj):
"owner": obj.admin,
"assignee": obj.assignee,
}
db_project = Project.objects.create(**data)
db_project = create_db_project(data)
create_dummy_db_tasks(obj, db_project)
projects.append(db_project)
......@@ -1157,7 +1189,7 @@ class ProjectPartialUpdateAPITestCase(APITestCase):
def _check_response(self, response, db_project, data):
self.assertEqual(response.status_code, status.HTTP_200_OK)
name = data.get("name", data.get("name", db_project.name))
name = data.get("name", db_project.name)
self.assertEqual(response.data["name"], name)
response_owner = response.data["owner"]["id"] if response.data["owner"] else None
db_owner = db_project.owner.id if db_project.owner else None
......@@ -1167,6 +1199,16 @@ class ProjectPartialUpdateAPITestCase(APITestCase):
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))
if data.get("labels"):
self.assertListEqual(
[label["name"] for label in data.get("labels") if not label.get("deleted", False)],
[label["name"] for label in response.data["labels"]]
)
else:
self.assertListEqual(
[label.name for label in db_project.label_set.all()],
[label["name"] for label in response.data["labels"]]
)
def _check_api_v1_projects_id(self, user, data):
for db_project in self.projects:
......@@ -1180,9 +1222,13 @@ class ProjectPartialUpdateAPITestCase(APITestCase):
def test_api_v1_projects_id_admin(self):
data = {
"name": "new name for the project",
"name": "project with some labels",
"owner_id": self.owner.id,
"bug_tracker": "https://new.bug.tracker",
"labels": [
{"name": "car"},
{"name": "person"}
],
}
self._check_api_v1_projects_id(self.admin, data)
......@@ -1205,6 +1251,103 @@ class ProjectPartialUpdateAPITestCase(APITestCase):
}
self._check_api_v1_projects_id(None, data)
class UpdateLabelsAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
def assertLabelsEqual(self, label1, label2):
self.assertEqual(label1.get("name", label2.get("name")), label2.get("name"))
self.assertEqual(label1.get("color", label2.get("color")), label2.get("color"))
def _check_response(self, response, db_object, data):
self.assertEqual(response.status_code, status.HTTP_200_OK)
db_labels = db_object.label_set.all()
response_labels = response.data["labels"]
for label in data["labels"]:
if label.get("id", None) is None:
self.assertLabelsEqual(
label,
[l for l in response_labels if label.get("name") == l.get("name")][0],
)
db_labels = [l for l in db_labels if label.get("name") != l.name]
response_labels = [l for l in response_labels if label.get("name") != l.get("name")]
else:
if not label.get("deleted", False):
self.assertLabelsEqual(
label,
[l for l in response_labels if label.get("id") == l.get("id")][0],
)
response_labels = [l for l in response_labels if label.get("id") != l.get("id")]
db_labels = [l for l in db_labels if label.get("id") != l.id]
else:
self.assertEqual(
len([l for l in response_labels if label.get("id") == l.get("id")]), 0
)
self.assertEqual(len(response_labels), len(db_labels))
class ProjectUpdateLabelsAPITestCase(UpdateLabelsAPITestCase):
@classmethod
def setUpTestData(cls):
project_data = {
"name": "Project with labels",
"bug_tracker": "https://new.bug.tracker",
"labels": [{
"name": "car",
"color": "#ff00ff",
"attributes": [{
"name": "bool_attribute",
"mutable": True,
"input_type": AttributeType.CHECKBOX,
"default_value": "true"
}],
}, {
"name": "person",
}]
}
create_db_users(cls)
db_project = create_db_project(project_data)
create_dummy_db_tasks(cls, db_project)
cls.project = db_project
def _check_api_v1_project(self, data):
response = self._run_api_v1_project_id(self.project.id, self.admin, data)
self._check_response(response, self.project, data)
def _run_api_v1_project_id(self, pid, user, data):
with ForceLogin(user, self.client):
response = self.client.patch('/api/v1/projects/{}'.format(pid),
data=data, format="json")
return response
def test_api_v1_projects_create_label(self):
data = {
"labels": [{
"name": "new label",
}],
}
self._check_api_v1_project(data)
def test_api_v1_projects_edit_label(self):
data = {
"labels": [{
"id": 1,
"name": "New name for label",
"color": "#fefefe",
}],
}
self._check_api_v1_project(data)
def test_api_v1_projects_delete_label(self):
data = {
"labels": [{
"id": 2,
"name": "Label for deletion",
"deleted": True
}]
}
self._check_api_v1_project(data)
class ProjectListOfTasksAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
......@@ -1566,6 +1709,73 @@ class TaskPartialUpdateAPITestCase(TaskUpdateAPITestCase):
}
self._check_api_v1_tasks_id(None, data)
class TaskUpdateLabelsAPITestCase(UpdateLabelsAPITestCase):
@classmethod
def setUpTestData(cls):
task_data = {
"name": "Project with labels",
"bug_tracker": "https://new.bug.tracker",
"overlap": 0,
"segment_size": 100,
"image_quality": 75,
"size": 100,
"labels": [{
"name": "car",
"color": "#ff00ff",
"attributes": [{
"name": "bool_attribute",
"mutable": True,
"input_type": AttributeType.CHECKBOX,
"default_value": "true"
}],
}, {
"name": "person",
}]
}
create_db_users(cls)
db_task = create_db_task(task_data)
cls.task = db_task
def _check_api_v1_task(self, data):
response = self._run_api_v1_task_id(self.task.id, self.admin, data)
self._check_response(response, self.task, data)
def _run_api_v1_task_id(self, tid, user, data):
with ForceLogin(user, self.client):
response = self.client.patch('/api/v1/tasks/{}'.format(tid),
data=data, format="json")
return response
def test_api_v1_tasks_create_label(self):
data = {
"labels": [{
"name": "new label",
}],
}
self._check_api_v1_task(data)
def test_api_v1_tasks_edit_label(self):
data = {
"labels": [{
"id": 1,
"name": "New name for label",
"color": "#fefefe",
}],
}
self._check_api_v1_task(data)
def test_api_v1_tasks_delete_label(self):
data = {
"labels": [{
"id": 2,
"name": "Label for deletion",
"deleted": True
}]
}
self._check_api_v1_task(data)
class TaskCreateAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
......
......@@ -19,6 +19,9 @@ os.makedirs(MEDIA_DATA_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)
MODELS_ROOT = os.path.join(DATA_ROOT, 'models')
os.makedirs(MODELS_ROOT, exist_ok=True)
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册