Add webhooks (#4863)

Co-authored-by: “klakhov” <kirill.lakhov@cvat.ai>
Co-authored-by: Boris <sekachev.bs@gmail.com>
Co-authored-by: kirill-sizov <kirill.sizov@intel.com>
main
Kirill Sizov 3 years ago committed by GitHub
parent 8b719e4959
commit bae7564968
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -17,7 +17,7 @@ module.exports = {
'.eslintrc.js', '.eslintrc.js',
'lint-staged.config.js', 'lint-staged.config.js',
], ],
plugins: ['@typescript-eslint', 'security', 'no-unsanitized', 'eslint-plugin-header', 'import'], plugins: ['@typescript-eslint', 'security', 'no-unsanitized', 'import'],
extends: [ extends: [
'eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM', 'eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM',
'airbnb-base', 'plugin:import/errors', 'plugin:import/warnings', 'airbnb-base', 'plugin:import/errors', 'plugin:import/warnings',

@ -124,6 +124,25 @@
"env": {}, "env": {},
"console": "internalConsole" "console": "internalConsole"
}, },
{
"name": "server: RQ - webhooks",
"type": "python",
"request": "launch",
"justMyCode": false,
"stopOnEntry": false,
"python": "${command:python.interpreterPath}",
"program": "${workspaceRoot}/manage.py",
"args": [
"rqworker",
"webhooks",
"--worker-class",
"cvat.simpleworker.SimpleWorker",
],
"django": true,
"cwd": "${workspaceFolder}",
"env": {},
"console": "internalConsole"
},
{ {
"name": "server: git", "name": "server: git",
"type": "python", "type": "python",
@ -285,6 +304,7 @@
"server: django", "server: django",
"server: RQ - default", "server: RQ - default",
"server: RQ - low", "server: RQ - low",
"server: RQ - webhooks",
"server: RQ - scheduler", "server: RQ - scheduler",
"server: git", "server: git",
] ]

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation // Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -17,13 +18,14 @@ const config = require('./config');
checkObjectType, checkObjectType,
} = require('./common'); } = require('./common');
const User = require('./user'); const User = require('./user').default;
const { AnnotationFormats } = require('./annotation-formats'); const { AnnotationFormats } = require('./annotation-formats');
const { ArgumentError } = require('./exceptions'); const { ArgumentError } = require('./exceptions');
const { Task, Job } = require('./session'); const { Task, Job } = require('./session');
const Project = require('./project').default; const Project = require('./project').default;
const { CloudStorage } = require('./cloud-storage'); const { CloudStorage } = require('./cloud-storage');
const Organization = require('./organization'); const Organization = require('./organization');
const Webhook = require('./webhook').default;
function implementAPI(cvat) { function implementAPI(cvat) {
cvat.plugins.list.implementation = PluginRegistry.list; cvat.plugins.list.implementation = PluginRegistry.list;
@ -286,6 +288,39 @@ const config = require('./config');
config.organizationID = null; config.organizationID = null;
}; };
cvat.webhooks.get.implementation = async (filter) => {
checkFilter(filter, {
page: isInteger,
id: isInteger,
projectId: isInteger,
filter: isString,
search: isString,
sort: isString,
});
checkExclusiveFields(filter, ['id', 'projectId'], ['page']);
const searchParams = {};
for (const key of Object.keys(filter)) {
if (['page', 'id', 'filter', 'search', 'sort'].includes(key)) {
searchParams[key] = filter[key];
}
}
if (filter.projectId) {
if (searchParams.filter) {
const parsed = JSON.parse(searchParams.filter);
searchParams.filter = JSON.stringify({ and: [parsed, { '==': [{ var: 'project_id' }, filter.projectId] }] });
} else {
searchParams.filter = JSON.stringify({ and: [{ '==': [{ var: 'project_id' }, filter.projectId] }] });
}
}
const webhooksData = await serverProxy.webhooks.get(searchParams);
const webhooks = webhooksData.map((webhookData) => new Webhook(webhookData));
webhooks.count = webhooksData.count;
return webhooks;
};
return cvat; return cvat;
} }

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation // Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -23,6 +24,7 @@ function build() {
const { FrameData } = require('./frames'); const { FrameData } = require('./frames');
const { CloudStorage } = require('./cloud-storage'); const { CloudStorage } = require('./cloud-storage');
const Organization = require('./organization'); const Organization = require('./organization');
const Webhook = require('./webhook').default;
const enums = require('./enums'); const enums = require('./enums');
@ -30,7 +32,7 @@ function build() {
Exception, ArgumentError, DataError, ScriptingError, PluginError, ServerError, Exception, ArgumentError, DataError, ScriptingError, PluginError, ServerError,
} = require('./exceptions'); } = require('./exceptions');
const User = require('./user'); const User = require('./user').default;
const pjson = require('../package.json'); const pjson = require('../package.json');
const config = require('./config'); const config = require('./config');
@ -843,6 +845,26 @@ function build() {
return result; return result;
}, },
}, },
/**
* This namespace could be used to get webhooks list from the server
* @namespace webhooks
* @memberof module:API.cvat
*/
webhooks: {
/**
* Method returns a list of organizations
* @method get
* @async
* @memberof module:API.cvat.webhooks
* @returns {module:API.cvat.classes.Webhook[]}
* @throws {module:API.cvat.exceptions.PluginError}
* @throws {module:API.cvat.exceptions.ServerError}
*/
async get(filter: any) {
const result = await PluginRegistry.apiWrapper(cvat.webhooks.get, filter);
return result;
},
},
/** /**
* Namespace is used for access to classes * Namespace is used for access to classes
* @namespace classes * @namespace classes
@ -864,6 +886,7 @@ function build() {
FrameData, FrameData,
CloudStorage, CloudStorage,
Organization, Organization,
Webhook,
}, },
}; };

@ -1,8 +1,9 @@
// Copyright (C) 2020-2022 Intel Corporation // Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
const User = require('./user'); const User = require('./user').default;
const { ArgumentError } = require('./exceptions'); const { ArgumentError } = require('./exceptions');
/** /**

@ -438,3 +438,29 @@ export enum StorageLocation {
LOCAL = 'local', LOCAL = 'local',
CLOUD_STORAGE = 'cloud_storage', CLOUD_STORAGE = 'cloud_storage',
} }
/**
* Webhook source types
* @enum {string}
* @name WebhookSourceType
* @memberof module:API.cvat.enums
* @property {string} ORGANIZATION 'organization'
* @property {string} PROJECT 'project'
* @readonly
*/
export enum WebhookSourceType {
ORGANIZATION = 'organization',
PROJECT = 'project',
}
/**
* Webhook content types
* @enum {string}
* @name WebhookContentType
* @memberof module:API.cvat.enums
* @property {string} JSON 'json'
* @readonly
*/
export enum WebhookContentType {
JSON = 'application/json',
}

@ -1,4 +1,5 @@
// Copyright (C) 2020-2022 Intel Corporation // Copyright (C) 2020-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -6,7 +7,7 @@ const quickhull = require('quickhull');
const PluginRegistry = require('./plugins').default; const PluginRegistry = require('./plugins').default;
const Comment = require('./comment'); const Comment = require('./comment');
const User = require('./user'); const User = require('./user').default;
const { ArgumentError } = require('./exceptions'); const { ArgumentError } = require('./exceptions');
const serverProxy = require('./server-proxy').default; const serverProxy = require('./server-proxy').default;

@ -1,4 +1,5 @@
// Copyright (C) 2021-2022 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -8,7 +9,7 @@ const { MembershipRole } = require('./enums');
const { ArgumentError, ServerError } = require('./exceptions'); const { ArgumentError, ServerError } = require('./exceptions');
const PluginRegistry = require('./plugins').default; const PluginRegistry = require('./plugins').default;
const serverProxy = require('./server-proxy').default; const serverProxy = require('./server-proxy').default;
const User = require('./user'); const User = require('./user').default;
/** /**
* Class representing an organization * Class representing an organization

@ -9,7 +9,7 @@ import { Storage } from './storage';
const PluginRegistry = require('./plugins').default; const PluginRegistry = require('./plugins').default;
const { ArgumentError } = require('./exceptions'); const { ArgumentError } = require('./exceptions');
const { Label } = require('./labels'); const { Label } = require('./labels');
const User = require('./user'); const User = require('./user').default;
const { FieldUpdateTrigger } = require('./common'); const { FieldUpdateTrigger } = require('./common');
/** /**

@ -3,7 +3,7 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { StorageLocation } from './enums'; import { StorageLocation, WebhookSourceType } from './enums';
import { Storage } from './storage'; import { Storage } from './storage';
type Params = { type Params = {
@ -18,12 +18,11 @@ type Params = {
const FormData = require('form-data'); const FormData = require('form-data');
const store = require('store'); const store = require('store');
const Axios = require('axios');
const tus = require('tus-js-client');
const config = require('./config'); const config = require('./config');
const DownloadWorker = require('./download.worker'); const DownloadWorker = require('./download.worker');
const { ServerError } = require('./exceptions'); const { ServerError } = require('./exceptions');
const Axios = require('axios');
const tus = require('tus-js-client');
function enableOrganization() { function enableOrganization() {
return { org: config.organizationID || '' }; return { org: config.organizationID || '' };
@ -921,8 +920,8 @@ class ServerProxy {
} }
setTimeout(request); setTimeout(request);
}) });
}; }
const isCloudStorage = storage.location === StorageLocation.CLOUD_STORAGE; const isCloudStorage = storage.location === StorageLocation.CLOUD_STORAGE;
@ -2022,11 +2021,160 @@ class ServerProxy {
response = await Axios.get(`${backendAPI}/invitations/${id}`, { response = await Axios.get(`${backendAPI}/invitations/${id}`, {
proxy: config.proxy, proxy: config.proxy,
}); });
return response.data;
} catch (errorData) { } catch (errorData) {
throw generateError(errorData); throw generateError(errorData);
} }
}
async function getWebhookDelivery(webhookID: number, deliveryID: number): Promise<any> {
const params = enableOrganization();
const { backendAPI } = config;
try {
const response = await Axios.get(`${backendAPI}/webhooks/${webhookID}/deliveries/${deliveryID}`, {
proxy: config.proxy,
params,
headers: {
'Content-Type': 'application/json',
},
});
return response.data; return response.data;
} catch (errorData) {
throw generateError(errorData);
}
}
async function getWebhooks(filter, pageSize = 10): Promise<any> {
const params = enableOrganization();
const { backendAPI } = config;
try {
const response = await Axios.get(`${backendAPI}/webhooks`, {
proxy: config.proxy,
params: {
...params,
...filter,
page_size: pageSize,
},
headers: {
'Content-Type': 'application/json',
},
});
response.data.results.count = response.data.count;
return response.data.results;
} catch (errorData) {
throw generateError(errorData);
}
}
async function createWebhook(webhookData: any): Promise<any> {
const params = enableOrganization();
const { backendAPI } = config;
try {
const response = await Axios.post(`${backendAPI}/webhooks`, JSON.stringify(webhookData), {
proxy: config.proxy,
params,
headers: {
'Content-Type': 'application/json',
},
});
return response.data;
} catch (errorData) {
throw generateError(errorData);
}
}
async function updateWebhook(webhookID: number, webhookData: any): Promise<any> {
const params = enableOrganization();
const { backendAPI } = config;
try {
const response = await Axios
.patch(`${backendAPI}/webhooks/${webhookID}`, JSON.stringify(webhookData), {
proxy: config.proxy,
params,
headers: {
'Content-Type': 'application/json',
},
});
return response.data;
} catch (errorData) {
throw generateError(errorData);
}
}
async function deleteWebhook(webhookID: number): Promise<void> {
const params = enableOrganization();
const { backendAPI } = config;
try {
await Axios.delete(`${backendAPI}/webhooks/${webhookID}`, {
proxy: config.proxy,
params,
headers: {
'Content-Type': 'application/json',
},
});
} catch (errorData) {
throw generateError(errorData);
}
}
async function pingWebhook(webhookID: number): Promise<any> {
const params = enableOrganization();
const { backendAPI } = config;
async function waitPingDelivery(deliveryID: number): Promise<any> {
return new Promise((resolve) => {
async function checkStatus(): Promise<any> {
const delivery = await getWebhookDelivery(webhookID, deliveryID);
if (delivery.status_code) {
resolve(delivery);
} else {
setTimeout(checkStatus, 1000);
}
}
setTimeout(checkStatus, 1000);
});
}
try {
const response = await Axios.post(`${backendAPI}/webhooks/${webhookID}/ping`, {
proxy: config.proxy,
params,
headers: {
'Content-Type': 'application/json',
},
});
const deliveryID = response.data.id;
const delivery = await waitPingDelivery(deliveryID);
return delivery;
} catch (errorData) {
throw generateError(errorData);
}
}
async function receiveWebhookEvents(type: WebhookSourceType): Promise<string[]> {
const { backendAPI } = config;
try {
const response = await Axios.get(`${backendAPI}/webhooks/events`, {
proxy: config.proxy,
params: {
type,
},
headers: {
'Content-Type': 'application/json',
},
});
return response.data.events;
} catch (errorData) {
throw generateError(errorData);
}
} }
Object.defineProperties( Object.defineProperties(
@ -2189,6 +2337,18 @@ class ServerProxy {
}), }),
writable: false, writable: false,
}, },
webhooks: {
value: Object.freeze({
get: getWebhooks,
create: createWebhook,
update: updateWebhook,
delete: deleteWebhook,
ping: pingWebhook,
events: receiveWebhookEvents,
}),
writable: false,
},
}), }),
); );
} }

@ -26,7 +26,7 @@ const {
JobStage, JobState, HistoryActions, JobStage, JobState, HistoryActions,
} = require('./enums'); } = require('./enums');
const { Label } = require('./labels'); const { Label } = require('./labels');
const User = require('./user'); const User = require('./user').default;
const Issue = require('./issue'); const Issue = require('./issue');
const { FieldUpdateTrigger, checkObjectType } = require('./common'); const { FieldUpdateTrigger, checkObjectType } = require('./common');

@ -1,15 +1,38 @@
// Copyright (C) 2019-2022 Intel Corporation // Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
(() => { interface RawUserData {
/** id: number;
* Class representing a user username: string;
* @memberof module:API.cvat.classes email: string;
* @hideconstructor first_name: string;
*/ last_name: string;
class User { groups: string[];
constructor(initialData) { last_login: string;
date_joined: string;
is_staff: boolean;
is_superuser: boolean;
is_active: boolean;
email_verification_required: boolean;
}
export default class User {
public readonly id: number;
public readonly username: string;
public readonly email: string;
public readonly firstName: string;
public readonly lastName: string;
public readonly groups: string[];
public readonly lastLogin: string;
public readonly dateJoined: string;
public readonly isStaff: boolean;
public readonly isSuperuser: boolean;
public readonly isActive: boolean;
public readonly isVerified: boolean;
constructor(initialData: RawUserData) {
const data = { const data = {
id: null, id: null,
username: null, username: null,
@ -158,7 +181,7 @@
); );
} }
serialize() { serialize(): RawUserData {
return { return {
id: this.id, id: this.id,
username: this.username, username: this.username,
@ -174,7 +197,4 @@
email_verification_required: this.isVerified, email_verification_required: this.isVerified,
}; };
} }
} }
module.exports = User;
})();

@ -0,0 +1,351 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import PluginRegistry from './plugins';
import User from './user';
import serverProxy from './server-proxy';
import { WebhookSourceType, WebhookContentType } from './enums';
import { isEnum } from './common';
interface RawWebhookData {
id?: number;
type: WebhookSourceType;
target_url: string;
organization_id?: number;
project_id?: number;
events: string[];
content_type: WebhookContentType;
secret?: string;
enable_ssl: boolean;
description?: string;
is_active?: boolean;
owner?: any;
created_date?: string;
updated_date?: string;
last_delivery_date?: string;
last_status?: number;
}
export default class Webhook {
public readonly id: number;
public readonly type: WebhookSourceType;
public readonly organizationID: number | null;
public readonly projectID: number | null;
public readonly owner: User;
public readonly lastStatus: number;
public readonly lastDeliveryDate?: string;
public readonly createdDate: string;
public readonly updatedDate: string;
public targetURL: string;
public events: string[];
public contentType: RawWebhookData['content_type'];
public description?: string;
public secret?: string;
public isActive?: boolean;
public enableSSL: boolean;
static async availableEvents(type: WebhookSourceType): Promise<string[]> {
return serverProxy.webhooks.events(type);
}
constructor(initialData: RawWebhookData) {
const data: RawWebhookData = {
id: undefined,
target_url: '',
type: WebhookSourceType.ORGANIZATION,
events: [],
content_type: WebhookContentType.JSON,
organization_id: null,
project_id: null,
description: undefined,
secret: '',
is_active: undefined,
enable_ssl: undefined,
owner: undefined,
created_date: undefined,
updated_date: undefined,
last_delivery_date: undefined,
last_status: 0,
};
for (const property in data) {
if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) {
data[property] = initialData[property];
}
}
if (data.owner) {
data.owner = new User(data.owner);
}
Object.defineProperties(
this,
Object.freeze({
id: {
get: () => data.id,
},
type: {
get: () => data.type,
},
targetURL: {
get: () => data.target_url,
set: (value: string) => {
if (typeof value !== 'string') {
throw ArgumentError(
`targetURL property must be a string, tried to set ${typeof value}`,
);
}
data.target_url = value;
},
},
events: {
get: () => data.events,
set: (events: string[]) => {
if (!Array.isArray(events)) {
throw ArgumentError(
`Events must be an array, tried to set ${typeof events}`,
);
}
events.forEach((event: string) => {
if (typeof event !== 'string') {
throw ArgumentError(
`Event must be a string, tried to set ${typeof event}`,
);
}
});
data.events = [...events];
},
},
contentType: {
get: () => data.content_type,
set: (value: WebhookContentType) => {
if (!isEnum.call(WebhookContentType, value)) {
throw new ArgumentError(
`Webhook contentType must be member of WebhookContentType,
got wrong value ${typeof value}`,
);
}
data.content_type = value;
},
},
organizationID: {
get: () => data.organization_id,
},
projectID: {
get: () => data.project_id,
},
description: {
get: () => data.description,
set: (value: string) => {
if (typeof value !== 'string') {
throw ArgumentError(
`Description property must be a string, tried to set ${typeof value}`,
);
}
data.description = value;
},
},
secret: {
get: () => data.secret,
set: (value: string) => {
if (typeof value !== 'string') {
throw ArgumentError(
`Secret property must be a string, tried to set ${typeof value}`,
);
}
data.secret = value;
},
},
isActive: {
get: () => data.is_active,
set: (value: boolean) => {
if (typeof value !== 'boolean') {
throw ArgumentError(
`isActive property must be a boolean, tried to set ${typeof value}`,
);
}
data.is_active = value;
},
},
enableSSL: {
get: () => data.enable_ssl,
set: (value: boolean) => {
if (typeof value !== 'boolean') {
throw ArgumentError(
`enableSSL property must be a boolean, tried to set ${typeof value}`,
);
}
data.enable_ssl = value;
},
},
owner: {
get: () => data.owner,
},
createdDate: {
get: () => data.created_date,
},
updatedDate: {
get: () => data.updated_date,
},
lastDeliveryDate: {
get: () => data.last_delivery_date,
},
lastStatus: {
get: () => data.last_status,
},
}),
);
}
public toJSON(): RawWebhookData {
const result: RawWebhookData = {
target_url: this.targetURL,
events: [...this.events],
content_type: this.contentType,
enable_ssl: this.enableSSL,
type: this.type || WebhookSourceType.ORGANIZATION,
};
if (Number.isInteger(this.id)) {
result.id = this.id;
}
if (Number.isInteger(this.organizationID)) {
result.organization_id = this.organizationID;
}
if (Number.isInteger(this.projectID)) {
result.project_id = this.projectID;
}
if (this.description) {
result.description = this.description;
}
if (this.secret) {
result.secret = this.secret;
}
if (typeof this.isActive === 'boolean') {
result.is_active = this.isActive;
}
return result;
}
public async save(): Promise<Webhook> {
const result = await PluginRegistry.apiWrapper.call(this, Webhook.prototype.save);
return result;
}
public async delete(): Promise<void> {
const result = await PluginRegistry.apiWrapper.call(this, Webhook.prototype.delete);
return result;
}
public async ping(): Promise<void> {
const result = await PluginRegistry.apiWrapper.call(this, Webhook.prototype.ping);
return result;
}
}
interface RawWebhookDeliveryData {
id?: number;
event?: string;
webhook_id?: number;
status_code?: string;
redelivery?: boolean;
created_date?: string;
updated_date?: string;
}
export class WebhookDelivery {
public readonly id?: number;
public readonly event: string;
public readonly webhookId: number;
public readonly statusCode: string;
public readonly createdDate?: string;
public readonly updatedDate?: string;
constructor(initialData: RawWebhookDeliveryData) {
const data: RawWebhookDeliveryData = {
id: undefined,
event: '',
webhook_id: undefined,
status_code: 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];
}
}
Object.defineProperties(
this,
Object.freeze({
id: {
get: () => data.id,
},
event: {
get: () => data.event,
},
webhookId: {
get: () => data.webhook_id,
},
statusCode: {
get: () => data.status_code,
},
createdDate: {
get: () => data.created_date,
},
updatedDate: {
get: () => data.updated_date,
},
}),
);
}
}
Object.defineProperties(Webhook.prototype.save, {
implementation: {
writable: false,
enumerable: false,
value: async function implementation() {
if (Number.isInteger(this.id)) {
const result = await serverProxy.webhooks.update(this.id, this.toJSON());
return new Webhook(result);
}
const result = await serverProxy.webhooks.create(this.toJSON());
return new Webhook(result);
},
},
});
Object.defineProperties(Webhook.prototype.delete, {
implementation: {
writable: false,
enumerable: false,
value: async function implementation() {
if (Number.isInteger(this.id)) {
await serverProxy.webhooks.delete(this.id);
}
},
},
});
Object.defineProperties(Webhook.prototype.ping, {
implementation: {
writable: false,
enumerable: false,
value: async function implementation() {
const result = await serverProxy.webhooks.ping(this.id);
return new WebhookDelivery(result);
},
},
});

@ -14,7 +14,7 @@ jest.mock('../../src/server-proxy', () => {
// Initialize api // Initialize api
window.cvat = require('../../src/api'); window.cvat = require('../../src/api');
const User = require('../../src/user'); const User = require('../../src/user').default;
// Test cases // Test cases
describe('Feature: get a list of users', () => { describe('Feature: get a list of users', () => {

@ -0,0 +1,124 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
// Setup mock for a server
jest.mock('../../src/server-proxy', () => {
return {
__esModule: true,
default: require('../mocks/server-proxy.mock'),
};
});
// Initialize api
window.cvat = require('../../src/api');
const Webhook = require('../../src/webhook').default;
const { webhooksDummyData, webhooksEventsDummyData } = require('../mocks/dummy-data.mock');
const { WebhookSourceType } = require('../../src/enums');
describe('Feature: get webhooks', () => {
test('get all webhooks', async () => {
const result = await window.cvat.webhooks.get({});
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(webhooksDummyData.count);
for (const item of result) {
expect(item).toBeInstanceOf(Webhook);
}
});
test('get webhook events', async () => {
function checkEvents(events) {
expect(Array.isArray(result)).toBeTruthy();
for (const event of events) {
expect(event).toMatch(/((create)|(update)|(delete)):/);
}
}
let result = await Webhook.availableEvents(WebhookSourceType.PROJECT);
checkEvents(result);
result = await Webhook.availableEvents(WebhookSourceType.ORGANIZATION);
checkEvents(result);
});
test('get webhook by id', async () => {
const result = await window.cvat.webhooks.get({
id: 1,
});
const [webhook] = result;
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(1);
expect(webhook).toBeInstanceOf(Webhook);
expect(webhook.id).toBe(1);
expect(webhook.targetURL).toBe('https://localhost:3001/project/hook');
expect(webhook.description).toBe('Project webhook');
expect(webhook.contentType).toBe('application/json');
expect(webhook.enableSSL).toBeTruthy();
expect(webhook.events).toEqual(webhooksEventsDummyData[WebhookSourceType.PROJECT].events);
});
});
describe('Feature: create a webhook', () => {
test('create new webhook', async () => {
const webhook = new window.cvat.classes.Webhook({
description: 'New webhook',
target_url: 'https://localhost:3001/hook',
content_type: 'application/json',
secret: 'secret',
enable_ssl: true,
is_active: true,
events: webhooksEventsDummyData[WebhookSourceType.PROJECT].events,
project_id: 1,
type:WebhookSourceType.PROJECT,
});
const result = await webhook.save();
expect(typeof result.id).toBe('number');
});
});
describe('Feature: update a webhook', () => {
test('update some webhook fields', async () => {
const newValues = new Map([
['description', 'New description'],
['isActive', false],
['targetURL', 'https://localhost:3001/new/url'],
]);
let result = await window.cvat.webhooks.get({
id: 1,
});
let [webhook] = result;
for (const [key, value] of newValues) {
webhook[key] = value;
}
webhook.save();
result = await window.cvat.webhooks.get({
id: 1,
});
[webhook] = result;
newValues.forEach((value, key) => {
expect(webhook[key]).toBe(value);
});
});
});
describe('Feature: delete a webhook', () => {
test('delete a webhook', async () => {
let result = await window.cvat.webhooks.get({
id: 2,
});
const [webhook] = result;
await webhook.delete();
result = await window.cvat.webhooks.get({
id: 2,
});
expect(Array.isArray(result)).toBeTruthy();
expect(result).toHaveLength(0);
});
});

@ -2723,7 +2723,7 @@ const taskAnnotationsDummyData = {
], ],
id: 28, id: 28,
frame: 0, frame: 0,
label_id:59, label_id: 59,
group: 0, group: 0,
source: 'manual', source: 'manual',
attributes: [] attributes: []
@ -2989,7 +2989,7 @@ const frameMetaDummyData = {
start_frame: 0, start_frame: 0,
stop_frame: 8, stop_frame: 8,
frame_filter: '', frame_filter: '',
deleted_frames: [7,8], deleted_frames: [7, 8],
frames: [ frames: [
{ {
width: 1920, width: 1920,
@ -3282,6 +3282,165 @@ const cloudStoragesDummyData = {
] ]
}; };
const webhooksDummyData = {
count: 3,
next: null,
previous: null,
results: [
{
id: 1,
url: "http://localhost:7000/api/webhooks/1",
target_url: "https://localhost:3001/project/hook",
description: "Project webhook",
type: "project",
content_type: "application/json",
is_active: true,
enable_ssl: true,
created_date: "2022-09-23T06:29:12.337276Z",
updated_date: "2022-09-23T06:29:12.337316Z",
owner: {
url: "http://localhost:7000/api/users/1",
id: 1,
username: "kirill",
first_name: "",
last_name: ""
},
project: 1,
organization: 1,
events: [
"create:comment",
"create:issue",
"create:task",
"delete:comment",
"delete:issue",
"delete:task",
"update:comment",
"update:job",
"update:project",
"update:task"
],
last_status: "Failed to connect to target url",
last_delivery_date: "2022-09-23T06:28:48.313010Z"
},
{
id: 2,
url: "http://localhost:7000/api/webhooks/2",
target_url: "https://localhost:3001/example/route",
description: "Second webhook",
type: "organization",
content_type: "application/json",
is_active: true,
enable_ssl: true,
created_date: "2022-09-23T06:28:32.430437Z",
updated_date: "2022-09-23T06:28:32.430474Z",
owner: {
url: "http://localhost:7000/api/users/1",
id: 1,
username: "kirill",
first_name: "",
last_name: ""
},
project: 1,
organization: 1,
events: [
"create:project",
"create:task",
"delete:project",
"delete:task",
"update:job",
"update:project",
"update:task"
],
last_status: "200",
last_delivery_date: "2022-09-23T06:28:48.313010Z"
},
{
id: 3,
url: "http://localhost:7000/api/webhooks/3",
target_url: "https://localhost:3001/example1",
description: "Example webhook",
type: "organization",
content_type: "application/json",
is_active: true,
enable_ssl: true,
created_date: "2022-09-23T06:27:52.888204Z",
updated_date: "2022-09-23T06:27:52.888245Z",
owner: {
url: "http://localhost:7000/api/users/1",
id: 1,
username: "kirill",
first_name: "",
last_name: ""
},
project: 1,
organization: 1,
events: [
"create:comment",
"create:invitation",
"create:issue",
"create:project",
"create:task",
"delete:comment",
"delete:invitation",
"delete:issue",
"delete:membership",
"delete:project",
"delete:task",
"update:comment",
"update:invitation",
"update:job",
"update:membership",
"update:organization",
"update:project",
"update:task"
],
last_status: "200",
last_delivery_date: "2022-09-23T06:28:48.283962Z"
}
]
};
const webhooksEventsDummyData = {
project: {
webhook_type: "project",
events: [
"create:comment",
"create:issue",
"create:task",
"delete:comment",
"delete:issue",
"delete:task",
"update:comment",
"update:job",
"update:project",
"update:task"
]
},
organization: {
webhook_type: "organization",
events: [
"create:comment",
"create:invitation",
"create:issue",
"create:project",
"create:task",
"delete:comment",
"delete:invitation",
"delete:issue",
"delete:membership",
"delete:project",
"delete:task",
"update:comment",
"update:invitation",
"update:job",
"update:membership",
"update:organization",
"update:project",
"update:task"
]
},
}
module.exports = { module.exports = {
tasksDummyData, tasksDummyData,
projectsDummyData, projectsDummyData,
@ -3293,4 +3452,6 @@ module.exports = {
frameMetaDummyData, frameMetaDummyData,
formatsDummyData, formatsDummyData,
cloudStoragesDummyData, cloudStoragesDummyData,
webhooksDummyData,
webhooksEventsDummyData,
}; };

@ -13,6 +13,8 @@ const {
jobAnnotationsDummyData, jobAnnotationsDummyData,
frameMetaDummyData, frameMetaDummyData,
cloudStoragesDummyData, cloudStoragesDummyData,
webhooksDummyData,
webhooksEventsDummyData,
} = require('./dummy-data.mock'); } = require('./dummy-data.mock');
function QueryStringToJSON(query, ignoreList = []) { function QueryStringToJSON(query, ignoreList = []) {
@ -412,6 +414,71 @@ class ServerProxy {
} }
} }
async function getWebhooks(filter = '') {
const queries = QueryStringToJSON(filter);
const result = webhooksDummyData.results.filter((item) => {
for (const key in queries) {
if (Object.prototype.hasOwnProperty.call(queries, key)) {
if (queries[key] !== item[key]) {
return false;
}
}
}
return true;
});
return result;
}
async function createWebhook(webhookData) {
const id = Math.max(...webhooksDummyData.results.map((item) => item.id)) + 1;
webhooksDummyData.results.push({
id,
description: webhookData.description,
target_url: webhookData.target_url,
content_type: webhookData.content_type,
secret: webhookData.secret,
enable_ssl: webhookData.enable_ssl,
is_active: webhookData.is_active,
events: webhookData.events,
organization_id: webhookData.organization_id ? webhookData.organization_id : null,
project_id: webhookData.project_id ? webhookData.project_id : null,
type: webhookData.type,
owner: { id: 1 },
created_date: '2022-09-23T06:29:12.337276Z',
updated_date: '2022-09-23T06:29:12.337276Z',
});
const result = await getWebhooks(`?id=${id}`);
return result[0];
}
async function updateWebhook(webhookID, webhookData) {
const webhook = webhooksDummyData.results.find((item) => item.id === webhookID);
if (webhook) {
for (const prop in webhookData) {
if (
Object.prototype.hasOwnProperty.call(webhookData, prop) &&
Object.prototype.hasOwnProperty.call(webhook, prop)
) {
webhook[prop] = webhookData[prop];
}
}
}
return webhook;
}
async function receiveWebhookEvents(type) {
return webhooksEventsDummyData[type]?.events;
}
async function deleteWebhook(webhookID) {
const webhooks = webhooksDummyData.results;
const webhookIdx = webhooks.findIndex((item) => item.id === webhookID);
if (webhookIdx !== -1) {
webhooks.splice(webhookIdx);
}
}
Object.defineProperties( Object.defineProperties(
this, this,
Object.freeze({ Object.freeze({
@ -489,6 +556,17 @@ class ServerProxy {
}), }),
writable: false, writable: false,
}, },
webhooks: {
value: Object.freeze({
get: getWebhooks,
create: createWebhook,
update: updateWebhook,
delete: deleteWebhook,
events: receiveWebhookEvents,
}),
writable: false,
},
}), }),
); );
} }

@ -0,0 +1,113 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { getCore, Webhook } from 'cvat-core-wrapper';
import { Dispatch, ActionCreator, Store } from 'redux';
import { Indexable, WebhooksQuery } from 'reducers';
import { ActionUnion, createAction, ThunkAction } from 'utils/redux';
const cvat = getCore();
export enum WebhooksActionsTypes {
GET_WEBHOOKS = 'GET_WEBHOOKS',
GET_WEBHOOKS_SUCCESS = 'GET_WEBHOOKS_SUCCESS',
GET_WEBHOOKS_FAILED = 'GET_WEBHOOKS_FAILED',
CREATE_WEBHOOK = 'CREATE_WEBHOOK',
CREATE_WEBHOOK_SUCCESS = 'CREATE_WEBHOOK_SUCCESS',
CREATE_WEBHOOK_FAILED = 'CREATE_WEBHOOK_FAILED',
UPDATE_WEBHOOK = 'UPDATE_WEBHOOK',
UPDATE_WEBHOOK_SUCCESS = 'UPDATE_WEBHOOK_SUCCESS',
UPDATE_WEBHOOK_FAILED = 'UPDATE_WEBHOOK_FAILED',
DELETE_WEBHOOK = 'DELETE_WEBHOOK',
DELETE_WEBHOOK_SUCCESS = 'DELETE_WEBHOOK_SUCCESS',
DELETE_WEBHOOK_FAILED = 'DELETE_WEBHOOK_FAILED',
}
const webhooksActions = {
getWebhooks: (query: WebhooksQuery) => createAction(WebhooksActionsTypes.GET_WEBHOOKS, { query }),
getWebhooksSuccess: (webhooks: Webhook[], count: number) => createAction(
WebhooksActionsTypes.GET_WEBHOOKS_SUCCESS, { webhooks, count },
),
getWebhooksFailed: (error: any) => createAction(WebhooksActionsTypes.GET_WEBHOOKS_FAILED, { error }),
createWebhook: () => createAction(WebhooksActionsTypes.CREATE_WEBHOOK),
createWebhookSuccess: (webhook: Webhook) => createAction(WebhooksActionsTypes.CREATE_WEBHOOK_SUCCESS, { webhook }),
createWebhookFailed: (error: any) => createAction(WebhooksActionsTypes.CREATE_WEBHOOK_FAILED, { error }),
updateWebhook: () => createAction(WebhooksActionsTypes.UPDATE_WEBHOOK),
updateWebhookSuccess: (webhook: any) => createAction(WebhooksActionsTypes.UPDATE_WEBHOOK_SUCCESS, { webhook }),
updateWebhookFailed: (error: any) => createAction(WebhooksActionsTypes.UPDATE_WEBHOOK_FAILED, { error }),
deleteWebhook: () => createAction(WebhooksActionsTypes.DELETE_WEBHOOK),
deleteWebhookSuccess: () => createAction(WebhooksActionsTypes.DELETE_WEBHOOK_SUCCESS),
deleteWebhookFailed: (webhookID: number, error: any) => createAction(
WebhooksActionsTypes.DELETE_WEBHOOK_FAILED, { webhookID, error },
),
};
export const getWebhooksAsync = (query: WebhooksQuery): ThunkAction => (
async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
dispatch(webhooksActions.getWebhooks(query));
// We remove all keys with null values from the query
const filteredQuery = { ...query };
for (const key of Object.keys(query)) {
if ((filteredQuery as Indexable)[key] === null) {
delete (filteredQuery as Indexable)[key];
}
}
let result = null;
try {
result = await cvat.webhooks.get(filteredQuery);
} catch (error) {
dispatch(webhooksActions.getWebhooksFailed(error));
return;
}
const array: Array<Webhook> = Array.from(result);
dispatch(webhooksActions.getWebhooksSuccess(array, result.count));
}
);
export function createWebhookAsync(webhookData: Store): ThunkAction {
return async function (dispatch) {
const webhook = new cvat.classes.Webhook(webhookData);
dispatch(webhooksActions.createWebhook());
try {
const createdWebhook = await webhook.save();
dispatch(webhooksActions.createWebhookSuccess(createdWebhook));
} catch (error) {
dispatch(webhooksActions.createWebhookFailed(error));
throw error;
}
};
}
export function updateWebhookAsync(webhook: Webhook): ThunkAction {
return async function (dispatch) {
dispatch(webhooksActions.updateWebhook());
try {
const updatedWebhook = await webhook.save();
dispatch(webhooksActions.updateWebhookSuccess(updatedWebhook));
} catch (error) {
dispatch(webhooksActions.updateWebhookFailed(error));
throw error;
}
};
}
export function deleteWebhookAsync(webhook: Webhook): ThunkAction {
return async function (dispatch) {
try {
await webhook.delete();
dispatch(webhooksActions.deleteWebhookSuccess());
} catch (error) {
dispatch(webhooksActions.deleteWebhookFailed(webhook.id, error));
throw error;
}
};
}
export type WebhooksActions = ActionUnion<typeof webhooksActions>;

@ -48,6 +48,10 @@ import OrganizationPage from 'components/organization-page/organization-page';
import CreateOrganizationComponent from 'components/create-organization-page/create-organization-page'; import CreateOrganizationComponent from 'components/create-organization-page/create-organization-page';
import { ShortcutsContextProvider } from 'components/shortcuts.context'; import { ShortcutsContextProvider } from 'components/shortcuts.context';
import WebhooksPage from 'components/webhooks-page/webhooks-page';
import CreateWebhookPage from 'components/setup-webhook-pages/create-webhook-page';
import UpdateWebhookPage from 'components/setup-webhook-pages/update-webhook-page';
import AnnotationPageContainer from 'containers/annotation-page/annotation-page'; import AnnotationPageContainer from 'containers/annotation-page/annotation-page';
import { getCore } from 'cvat-core-wrapper'; import { getCore } from 'cvat-core-wrapper';
import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react';
@ -370,6 +374,7 @@ class CVATApplication extends React.PureComponent<CVATAppProps & RouteComponentP
<Route exact path='/projects' component={ProjectsPageComponent} /> <Route exact path='/projects' component={ProjectsPageComponent} />
<Route exact path='/projects/create' component={CreateProjectPageComponent} /> <Route exact path='/projects/create' component={CreateProjectPageComponent} />
<Route exact path='/projects/:id' component={ProjectPageComponent} /> <Route exact path='/projects/:id' component={ProjectPageComponent} />
<Route exact path='/projects/:id/webhooks' component={WebhooksPage} />
<Route exact path='/tasks' component={TasksPageContainer} /> <Route exact path='/tasks' component={TasksPageContainer} />
<Route exact path='/tasks/create' component={CreateTaskPageContainer} /> <Route exact path='/tasks/create' component={CreateTaskPageContainer} />
<Route exact path='/tasks/:id' component={TaskPageContainer} /> <Route exact path='/tasks/:id' component={TaskPageContainer} />
@ -391,6 +396,9 @@ class CVATApplication extends React.PureComponent<CVATAppProps & RouteComponentP
path='/organizations/create' path='/organizations/create'
component={CreateOrganizationComponent} component={CreateOrganizationComponent}
/> />
<Route exact path='/organization/webhooks' component={WebhooksPage} />
<Route exact path='/webhooks/create' component={CreateWebhookPage} />
<Route exact path='/webhooks/update/:id' component={UpdateWebhookPage} />
<Route exact path='/organization' component={OrganizationPage} /> <Route exact path='/organization' component={OrganizationPage} />
{isModelPluginActive && ( {isModelPluginActive && (
<Route exact path='/models' component={ModelsPageContainer} /> <Route exact path='/models' component={ModelsPageContainer} />

@ -227,7 +227,7 @@ function HeaderContainer(props: Props): JSX.Element {
const resetOrganization = (): void => { const resetOrganization = (): void => {
localStorage.removeItem('currentOrganization'); localStorage.removeItem('currentOrganization');
if (/\d+$/.test(window.location.pathname)) { if (/(webhooks)|(\d+)/.test(window.location.pathname)) {
window.location.pathname = '/'; window.location.pathname = '/';
} else { } else {
window.location.reload(); window.location.reload();
@ -237,7 +237,7 @@ function HeaderContainer(props: Props): JSX.Element {
const setNewOrganization = (organization: any): void => { const setNewOrganization = (organization: any): void => {
if (!currentOrganization || currentOrganization.slug !== organization.slug) { if (!currentOrganization || currentOrganization.slug !== organization.slug) {
localStorage.setItem('currentOrganization', organization.slug); localStorage.setItem('currentOrganization', organization.slug);
if (/\d+$/.test(window.location.pathname)) { if (/\d+/.test(window.location.pathname)) {
// a resource is opened (task/job/etc.) // a resource is opened (task/job/etc.)
window.location.pathname = '/'; window.location.pathname = '/';
} else { } else {

@ -1,4 +1,5 @@
// Copyright (C) 2021-2022 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -148,3 +149,7 @@
.cvat-organization-invitation-field { .cvat-organization-invitation-field {
align-items: baseline; align-items: baseline;
} }
.cvat-organization-page-actions-button {
padding-right: $grid-unit-size * 0.5;
}

@ -1,4 +1,5 @@
// Copyright (C) 2021-2022 Intel Corporation // Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -13,11 +14,14 @@ import Space from 'antd/lib/space';
import Input from 'antd/lib/input'; import Input from 'antd/lib/input';
import Form from 'antd/lib/form'; import Form from 'antd/lib/form';
import Select from 'antd/lib/select'; import Select from 'antd/lib/select';
import Dropdown from 'antd/lib/dropdown';
import Menu from 'antd/lib/menu';
import { useForm } from 'antd/lib/form/Form'; import { useForm } from 'antd/lib/form/Form';
import { Store } from 'antd/lib/form/interface'; import { Store } from 'antd/lib/form/interface';
import { import {
EditTwoTone, EnvironmentOutlined, EditTwoTone, EnvironmentOutlined,
MailOutlined, PhoneOutlined, PlusCircleOutlined, DeleteOutlined, MailOutlined, PhoneOutlined, PlusCircleOutlined, DeleteOutlined, MoreOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
@ -26,6 +30,7 @@ import {
removeOrganizationAsync, removeOrganizationAsync,
updateOrganizationAsync, updateOrganizationAsync,
} from 'actions/organization-actions'; } from 'actions/organization-actions';
import { useHistory } from 'react-router-dom';
export interface Props { export interface Props {
organizationInstance: any; organizationInstance: any;
@ -33,6 +38,11 @@ export interface Props {
fetchMembers: () => void; fetchMembers: () => void;
} }
export enum MenuActions {
SET_WEBHOOKS = 'SET_WEBHOOKS',
REMOVE_ORGANIZATION = 'REMOVE_ORGANIZATION',
}
function OrganizationTopBar(props: Props): JSX.Element { function OrganizationTopBar(props: Props): JSX.Element {
const { organizationInstance, userInstance, fetchMembers } = props; const { organizationInstance, userInstance, fetchMembers } = props;
const { const {
@ -62,14 +72,86 @@ function OrganizationTopBar(props: Props): JSX.Element {
let organizationName = name; let organizationName = name;
let organizationDescription = description; let organizationDescription = description;
let organizationContacts = contact; let organizationContacts = contact;
const history = useHistory();
return ( return (
<> <>
<Row justify='space-between'> <Row justify='space-between'>
<Col span={24}> <Col span={24}>
<div className='cvat-organization-top-bar-descriptions'> <div className='cvat-organization-top-bar-descriptions'>
<Row justify='space-between'>
<Col>
<Text> <Text>
<Text className='cvat-title'>{`Organization: ${slug} `}</Text> <Text className='cvat-title'>{`Organization: ${slug} `}</Text>
</Text> </Text>
</Col>
<Col>
<Dropdown overlay={() => (
<Menu className='cvat-organization-actions-menu'>
<Menu.Item key={MenuActions.SET_WEBHOOKS}>
<a
href='/organization/webhooks'
onClick={(e: React.MouseEvent) => {
e.preventDefault();
history.push({
pathname: '/organization/webhooks',
});
return false;
}}
>
Setup webhooks
</a>
</Menu.Item>
{owner && userID === owner.id ? (
<Menu.Item
key={MenuActions.REMOVE_ORGANIZATION}
onClick={() => {
const modal = Modal.confirm({
onOk: () => {
dispatch(removeOrganizationAsync(organizationInstance));
},
content: (
<div className='cvat-remove-organization-submit'>
<Text type='warning'>
To remove the organization,
enter its short name below
</Text>
<Input
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
modal.update({
okButtonProps: {
disabled:
event.target.value !== organizationInstance.slug,
danger: true,
},
});
}}
/>
</div>
),
okButtonProps: {
disabled: true,
danger: true,
},
okText: 'Remove',
});
}}
>
Remove organization
</Menu.Item>
) : null}
</Menu>
)}
>
<Button size='middle' className='cvat-organization-page-actions-button'>
<Text className='cvat-text-color'>Actions</Text>
<MoreOutlined className='cvat-menu-icon' />
</Button>
</Dropdown>
</Col>
</Row>
<Text <Text
editable={{ editable={{
onChange: (value: string) => { onChange: (value: string) => {
@ -213,44 +295,6 @@ function OrganizationTopBar(props: Props): JSX.Element {
Leave organization Leave organization
</Button> </Button>
) : null} ) : null}
{owner && userID === owner.id ? (
<Button
type='primary'
danger
onClick={() => {
const modal = Modal.confirm({
onOk: () => {
dispatch(removeOrganizationAsync(organizationInstance));
},
content: (
<div className='cvat-remove-organization-submit'>
<Text type='warning'>
To remove the organization, enter its short name below
</Text>
<Input
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
modal.update({
okButtonProps: {
disabled:
event.target.value !== organizationInstance.slug,
danger: true,
},
});
}}
/>
</div>
),
okButtonProps: {
disabled: true,
danger: true,
},
okText: 'Remove',
});
}}
>
Remove organization
</Button>
) : null}
<Button <Button
type='primary' type='primary'
onClick={() => setVisibleInviteModal(true)} onClick={() => setVisibleInviteModal(true)}

@ -12,6 +12,7 @@ import { CombinedState } from 'reducers';
import { deleteProjectAsync } from 'actions/projects-actions'; import { deleteProjectAsync } from 'actions/projects-actions';
import { exportActions } from 'actions/export-actions'; import { exportActions } from 'actions/export-actions';
import { importActions } from 'actions/import-actions'; import { importActions } from 'actions/import-actions';
import { useHistory } from 'react-router';
interface Props { interface Props {
projectInstance: any; projectInstance: any;
@ -20,6 +21,7 @@ interface Props {
export default function ProjectActionsMenuComponent(props: Props): JSX.Element { export default function ProjectActionsMenuComponent(props: Props): JSX.Element {
const { projectInstance } = props; const { projectInstance } = props;
const history = useHistory();
const dispatch = useDispatch(); const dispatch = useDispatch();
const exportBackupIsActive = useSelector((state: CombinedState) => ( const exportBackupIsActive = useSelector((state: CombinedState) => (
state.export.projects.backup.current[projectInstance.id] state.export.projects.backup.current[projectInstance.id]
@ -56,6 +58,20 @@ export default function ProjectActionsMenuComponent(props: Props): JSX.Element {
> >
Backup Project Backup Project
</Menu.Item> </Menu.Item>
<Menu.Item key='set-webhooks'>
<a
href={`/projects/${projectInstance.id}/webhooks`}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
history.push({
pathname: `/projects/${projectInstance.id}/webhooks`,
});
return false;
}}
>
Setup webhooks
</a>
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item key='delete' onClick={onDeleteProject}> <Menu.Item key='delete' onClick={onDeleteProject}>
Delete Delete

@ -1,4 +1,5 @@
// Copyright (C) 2022 Intel Corporation // Copyright (C) 2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -21,11 +22,11 @@ import { CombinedState } from 'reducers';
import { User } from 'components/task-page/user-selector'; import { User } from 'components/task-page/user-selector';
interface ResourceFilterProps { interface ResourceFilterProps {
predefinedVisible: boolean; predefinedVisible?: boolean;
recentVisible: boolean; recentVisible: boolean;
builderVisible: boolean; builderVisible: boolean;
value: string | null; value: string | null;
onPredefinedVisibleChange(visible: boolean): void; onPredefinedVisibleChange?: (visible: boolean) => void;
onBuilderVisibleChange(visible: boolean): void; onBuilderVisibleChange(visible: boolean): void;
onRecentVisibleChange(visible: boolean): void; onRecentVisibleChange(visible: boolean): void;
onApplyFilter(filter: string | null): void; onApplyFilter(filter: string | null): void;
@ -35,7 +36,7 @@ export default function ResourceFilterHOC(
filtrationCfg: Partial<Config>, filtrationCfg: Partial<Config>,
localStorageRecentKeyword: string, localStorageRecentKeyword: string,
localStorageRecentCapacity: number, localStorageRecentCapacity: number,
predefinedFilterValues: Record<string, string>, predefinedFilterValues?: Record<string, string>,
): React.FunctionComponent<ResourceFilterProps> { ): React.FunctionComponent<ResourceFilterProps> {
const config: Config = { ...AntdConfig, ...filtrationCfg }; const config: Config = { ...AntdConfig, ...filtrationCfg };
const defaultTree = QbUtils.checkTree( const defaultTree = QbUtils.checkTree(
@ -100,9 +101,10 @@ export default function ResourceFilterHOC(
return filters[0]; return filters[0];
} }
function getPredefinedFilters(user: User): Record<string, string> { function getPredefinedFilters(user: User): Record<string, string> | null {
const result: Record<string, string> = {}; let result: Record <string, string> | null = null;
if (user) { if (user && predefinedFilterValues) {
result = {};
for (const key of Object.keys(predefinedFilterValues)) { for (const key of Object.keys(predefinedFilterValues)) {
result[key] = predefinedFilterValues[key].replace('<username>', `${user.username}`); result[key] = predefinedFilterValues[key].replace('<username>', `${user.username}`);
} }
@ -190,6 +192,8 @@ export default function ResourceFilterHOC(
const predefinedFilters = getPredefinedFilters(user); const predefinedFilters = getPredefinedFilters(user);
return ( return (
<div className='cvat-resource-page-filters'> <div className='cvat-resource-page-filters'>
{
predefinedFilters && onPredefinedVisibleChange ? (
<Dropdown <Dropdown
destroyPopupOnHide destroyPopupOnHide
visible={predefinedVisible} visible={predefinedVisible}
@ -234,6 +238,8 @@ export default function ResourceFilterHOC(
<FilterOutlined />} <FilterOutlined />}
</Button> </Button>
</Dropdown> </Dropdown>
) : null
}
<Dropdown <Dropdown
placement='bottomRight' placement='bottomRight'
visible={builderVisible} visible={builderVisible}

@ -0,0 +1,48 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React from 'react';
import Button from 'antd/lib/button';
import { Row, Col } from 'antd/lib/grid';
import { LeftOutlined } from '@ant-design/icons';
import { useHistory, useLocation } from 'react-router';
import SetupWebhookContent from './setup-webhook-content';
function CreateWebhookPage(): JSX.Element {
const history = useHistory();
const location = useLocation();
const params = new URLSearchParams(location.search);
let defaultProjectId : number | null = null;
if (params.get('projectId')?.match(/^[1-9]+[0-9]*$/)) {
defaultProjectId = +(params.get('projectId') as string);
}
return (
<div className='cvat-create-webhook-page'>
<Row justify='center' align='middle'>
<Col md={20} lg={16} xl={14} xxl={9}>
<Button
className='cvat-webhooks-go-back'
onClick={() => (defaultProjectId ?
history.push(`/projects/${defaultProjectId}/webhooks`) :
history.push('/organization/webhooks'))}
type='link'
size='large'
>
<LeftOutlined />
Back to webhooks
</Button>
</Col>
</Row>
<Row justify='center' align='top' className='cvat-create-webhook-form-wrapper'>
<Col md={20} lg={16} xl={14} xxl={9}>
<SetupWebhookContent defaultProjectId={defaultProjectId} />
</Col>
</Row>
</div>
);
}
export default React.memo(CreateWebhookPage);

@ -0,0 +1,312 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useCallback, useEffect, useState } from 'react';
import { Store } from 'antd/lib/form/interface';
import { Row, Col } from 'antd/lib/grid';
import Form from 'antd/lib/form';
import Text from 'antd/lib/typography/Text';
import Button from 'antd/lib/button';
import Checkbox from 'antd/lib/checkbox/Checkbox';
import Input from 'antd/lib/input';
import Radio, { RadioChangeEvent } from 'antd/lib/radio';
import Select from 'antd/lib/select';
import notification from 'antd/lib/notification';
import { getCore, Webhook } from 'cvat-core-wrapper';
import ProjectSearchField from 'components/create-task-page/project-search-field';
import { useSelector, useDispatch } from 'react-redux';
import { CombinedState } from 'reducers';
import { createWebhookAsync, updateWebhookAsync } from 'actions/webhooks-actions';
export enum WebhookContentType {
APPLICATION_JSON = 'application/json',
}
export enum WebhookSourceType {
ORGANIZATION = 'organization',
PROJECT = 'project',
}
export enum EventsMethod {
SEND_EVERYTHING = 'SEND_EVERYTHING',
SELECT_INDIVIDUAL = 'SELECT_INDIVIDUAL',
}
export interface SetupWebhookData {
description: string;
targetUrl: string;
contentType: WebhookContentType;
secret: string;
enableSSL: boolean;
active: boolean;
eventsMethod: EventsMethod;
}
interface Props {
webhook?: any;
defaultProjectId: number | null;
}
export function groupEvents(events: string[]): string[] {
return Array.from(
new Set(events.map((event: string) => event.split(':')[1])),
);
}
function collectEvents(method: EventsMethod, submittedGroups: Record<string, any>, allEvents: string[]): string[] {
return method === EventsMethod.SEND_EVERYTHING ? allEvents : (() => {
const submittedEvents = Object.entries(submittedGroups).filter(([key, value]) => key.startsWith('event:') && value).map(([key]) => key)
.map((event: string) => event.split(':')[1]);
return allEvents.filter((event) => submittedEvents.includes(event.split(':')[1]));
})();
}
function SetupWebhookContent(props: Props): JSX.Element {
const dispatch = useDispatch();
const { webhook, defaultProjectId } = props;
const [form] = Form.useForm();
const [rerender, setRerender] = useState(false);
const [showDetailedEvents, setShowDetailedEvents] = useState(false);
const [webhookEvents, setWebhookEvents] = useState<string[]>([]);
const organization = useSelector((state: CombinedState) => state.organizations.current);
const [projectId, setProjectId] = useState<number | null>(defaultProjectId);
useEffect(() => {
const core = getCore();
if (webhook) {
core.classes.Webhook.availableEvents(webhook.type).then((events: string[]) => {
setWebhookEvents(events);
});
} else {
core.classes.Webhook.availableEvents(projectId ?
WebhookSourceType.PROJECT : WebhookSourceType.ORGANIZATION).then((events: string[]) => {
setWebhookEvents(events);
});
}
}, [projectId]);
useEffect(() => {
if (webhook) {
const eventsMethod = groupEvents(webhookEvents).length === groupEvents(webhook.events).length ?
EventsMethod.SEND_EVERYTHING : EventsMethod.SELECT_INDIVIDUAL;
setShowDetailedEvents(eventsMethod === EventsMethod.SELECT_INDIVIDUAL);
const data: Record<string, string | boolean> = {
description: webhook.description,
targetURL: webhook.targetURL,
contentType: webhook.contentType,
secret: webhook.secret,
enableSSL: webhook.enableSSL,
isActive: webhook.isActive,
events: webhook.events,
eventsMethod,
};
webhook.events.forEach((event: string) => {
data[`event:${event.split(':')[1]}`] = true;
});
form.setFieldsValue(data);
setRerender(!rerender);
}
}, [webhook, webhookEvents]);
const handleSubmit = useCallback(async (): Promise<Webhook | null> => {
try {
const values: Store = await form.validateFields();
let notificationConfig = {
message: 'Webhook has been successfully updated',
className: 'cvat-notification-update-webhook-success',
};
if (webhook) {
webhook.description = values.description;
webhook.targetURL = values.targetURL;
webhook.secret = values.secret;
webhook.contentType = values.contentType;
webhook.isActive = values.isActive;
webhook.enableSSL = values.enableSSL;
webhook.events = collectEvents(values.eventsMethod, values, webhookEvents);
await dispatch(updateWebhookAsync(webhook));
} else {
const rawWebhookData = {
description: values.description,
target_url: values.targetURL,
content_type: values.contentType,
secret: values.secret,
enable_ssl: values.enableSSL,
is_active: values.isActive,
events: collectEvents(values.eventsMethod, values, webhookEvents),
organization_id: projectId ? undefined : organization.id,
project_id: projectId,
type: projectId ? WebhookSourceType.PROJECT : WebhookSourceType.ORGANIZATION,
};
notificationConfig = {
message: 'Webhook has been successfully added',
className: 'cvat-notification-create-webhook-success',
};
await dispatch(createWebhookAsync(rawWebhookData));
}
form.resetFields();
setShowDetailedEvents(false);
notification.info(notificationConfig);
return webhook;
} catch (error) {
return null;
}
}, [webhook, webhookEvents]);
const onEventsMethodChange = useCallback((event: RadioChangeEvent): void => {
form.setFieldsValue({ eventsMethod: event.target.value });
setShowDetailedEvents(event.target.value === EventsMethod.SELECT_INDIVIDUAL);
setRerender(!rerender);
}, [rerender]);
return (
<Row justify='start' align='middle' className='cvat-setup-webhook-content'>
<Col span={24}>
<Text className='cvat-title'>Setup a webhook</Text>
</Col>
<Col span={24}>
<Form
form={form}
layout='vertical'
initialValues={{
contentType: WebhookContentType.APPLICATION_JSON,
eventsMethod: EventsMethod.SEND_EVERYTHING,
enableSSL: true,
isActive: true,
}}
>
<Form.Item
hasFeedback
name='targetURL'
label='Target URL'
rules={[
{
required: true,
message: 'Target URL cannot be empty',
},
]}
>
<Input placeholder='https://example.com/postreceive' />
</Form.Item>
<Form.Item
hasFeedback
name='description'
label='Description'
>
<Input />
</Form.Item>
{
!webhook && (
<Row className='ant-form-item'>
<Col className='ant-form-item-label' span={24}>
<Text className='cvat-text-color'>Project</Text>
</Col>
<Col span={24}>
<ProjectSearchField
onSelect={(_projectId: number | null) => setProjectId(_projectId)}
value={projectId}
/>
</Col>
</Row>
)
}
<Form.Item
hasFeedback
name='contentType'
label='Content type'
rules={[{ required: true }]}
>
<Select
placeholder='Select an option and change input text above'
>
<Select.Option value={WebhookContentType.APPLICATION_JSON}>
{WebhookContentType.APPLICATION_JSON}
</Select.Option>
</Select>
</Form.Item>
<Form.Item
name='secret'
label='Secret'
>
<Input />
</Form.Item>
<Form.Item
help='Verify SSL certificates when delivering payloads'
name='enableSSL'
valuePropName='checked'
>
<Checkbox>
<Text className='cvat-text-color'>Enable SSL</Text>
</Checkbox>
</Form.Item>
<Form.Item
help='CVAT will deliver events for active webhooks only'
name='isActive'
valuePropName='checked'
>
<Checkbox>
<Text className='cvat-text-color'>Active</Text>
</Checkbox>
</Form.Item>
<Form.Item
name='eventsMethod'
rules={[{
required: true,
message: 'The field is required',
}]}
>
<Radio.Group onChange={onEventsMethodChange}>
<Radio value={EventsMethod.SEND_EVERYTHING} key={EventsMethod.SEND_EVERYTHING}>
<Text>Send </Text>
<Text strong>everything</Text>
</Radio>
<Radio value={EventsMethod.SELECT_INDIVIDUAL} key={EventsMethod.SELECT_INDIVIDUAL}>
Select individual events
</Radio>
</Radio.Group>
</Form.Item>
{
showDetailedEvents && (
<Row className='cvat-webhook-detailed-events'>
{groupEvents(webhookEvents).map((event: string, idx: number) => (
<Col span={8} key={idx}>
<Form.Item
name={`event:${event}`}
valuePropName='checked'
>
<Checkbox>
<Text className='cvat-text-color'>{event}</Text>
</Checkbox>
</Form.Item>
</Col>
))}
</Row>
)
}
</Form>
</Col>
<Col span={24}>
<Row justify='end'>
<Col>
<Button type='primary' onClick={handleSubmit}>
Submit
</Button>
</Col>
</Row>
</Col>
</Row>
);
}
export default React.memo(SetupWebhookContent);

@ -0,0 +1,22 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-setup-webhook-content {
margin-top: $grid-unit-size;
width: 100%;
height: auto;
border: 1px solid $border-color-1;
border-radius: 3px;
padding: $grid-unit-size * 3;
background: $background-color-1;
text-align: initial;
}
.cvat-create-webhook-page {
width: 100%;
height: 100%;
padding-top: $grid-unit-size * 5;
}

@ -0,0 +1,52 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useEffect } from 'react';
import Button from 'antd/lib/button';
import { Row, Col } from 'antd/lib/grid';
import { LeftOutlined } from '@ant-design/icons';
import { useHistory, useParams } from 'react-router';
import { CombinedState } from 'reducers';
import { useDispatch, useSelector } from 'react-redux';
import { getWebhooksAsync } from 'actions/webhooks-actions';
import SetupWebhookContent from './setup-webhook-content';
interface ParamType {
id: string;
}
function UpdateWebhookPage(): JSX.Element {
const id = +useParams<ParamType>().id;
const history = useHistory();
const dispatch = useDispatch();
const webhooks = useSelector((state: CombinedState) => state.webhooks.current);
const [webhook] = webhooks.filter((_webhook) => _webhook.id === id);
useEffect(() => {
if (!webhook) {
dispatch(getWebhooksAsync({ id }));
}
}, []);
return (
<div className='cvat-create-webhook-page'>
<Row justify='center' align='middle'>
<Col md={20} lg={16} xl={14} xxl={9}>
<Button className='cvat-webhooks-go-back' onClick={() => history.goBack()} type='link' size='large'>
<LeftOutlined />
Back to webhooks
</Button>
</Col>
</Row>
<Row justify='center' align='top' className='cvat-create-webhook-form-wrapper'>
<Col md={20} lg={16} xl={14} xxl={9}>
<SetupWebhookContent webhook={webhook} defaultProjectId={webhook?.projectID || null} />
</Col>
</Row>
</div>
);
}
export default React.memo(UpdateWebhookPage);

@ -0,0 +1,35 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import Text from 'antd/lib/typography/Text';
import { Row, Col } from 'antd/lib/grid';
import Empty from 'antd/lib/empty';
import { WebhooksQuery } from 'reducers';
interface Props {
query: WebhooksQuery;
}
function EmptyWebhooksListComponent(props: Props): JSX.Element {
const { query } = props;
return (
<div className='cvat-empty-webhooks-list'>
<Empty description={!query.filter && !query.search ? (
<>
<Row justify='center' align='middle'>
<Col>
<Text strong>No webhooks created yet ...</Text>
</Col>
</Row>
</>
) : (<Text>No results matched your search</Text>)}
/>
</div>
);
}
export default React.memo(EmptyWebhooksListComponent);

@ -0,0 +1,136 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@import '../../styles.scss';
.cvat-webhooks-list {
height: 100%;
overflow-y: auto;
margin-top: $grid-unit-size * 3;
}
.cvat-webhooks-list-item {
width: 100%;
height: $grid-unit-size * 16;
border: 1px solid $border-color-1;
border-radius: 3px;
margin-bottom: $grid-unit-size * 2;
padding: $grid-unit-size * 2 0 $grid-unit-size * 0.5 0;
background: $background-color-1;
@media screen and (min-width: 1080px) {
height: $grid-unit-size * 15;
}
&:hover {
border: 1px solid $border-color-hover;
}
.ant-typography-ellipsis {
margin-bottom: 0;
}
}
.cvat-webhook-status {
margin-left: $grid-unit-size;
}
.cvat-webhook-status-available {
@extend .cvat-webhook-status;
color: green;
fill: green;
}
.cvat-webhook-status-failed {
@extend .cvat-webhook-status;
color: red;
fill: red;
}
.cvat-webhook-status-unavailable {
@extend .cvat-webhook-status;
color: gray;
fill: gray;
}
.cvat-webhook-info-text {
margin-right: $grid-unit-size;
}
.cvat-item-ping-webhook-button {
margin-right: $grid-unit-size * 3;
}
.cvat-webhooks-page-actions-button {
margin-right: $grid-unit-size;
margin-top: $grid-unit-size;
display: flex;
align-items: center;
padding: $grid-unit-size;
line-height: $grid-unit-size * 2;
}
.cvat-webhooks-page {
width: 100%;
height: 100%;
padding-top: $grid-unit-size * 3;
> div:nth-child(1) {
padding-bottom: $grid-unit-size;
}
> div:nth-child(3) {
height: 83%;
margin-bottom: $grid-unit-size * 4;
}
}
.cvat-empty-webhooks-list .ant-empty {
top: 50%;
left: 50%;
position: absolute;
transform: translate(-50%, -50%);
}
.cvat-webhooks-page-top-bar {
> button {
margin-right: $grid-unit-size;
}
> div {
display: flex;
justify-content: space-between;
}
}
.cvat-webhooks-page-search-bar {
width: $grid-unit-size * 32;
}
.cvat-webhooks-page-filters-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
> div {
display: flex;
margin-right: $grid-unit-size * 4;
> button {
margin-right: $grid-unit-size;
}
}
}
.cvat-webhooks-add-wrapper {
display: inline-block;
}
.cvat-webhooks-go-back {
padding: 0.5 * $grid-unit-size 0;
}

@ -0,0 +1,94 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import React, { useState } from 'react';
import { Row, Col } from 'antd/lib/grid';
import { PlusOutlined } from '@ant-design/icons';
import Button from 'antd/lib/button';
import Input from 'antd/lib/input';
import { SortingComponent, ResourceFilterHOC, defaultVisibility } from 'components/resource-sorting-filtering';
import { WebhooksQuery } from 'reducers';
import {
localStorageRecentKeyword, localStorageRecentCapacity, config,
} from './webhooks-filter-configuration';
const FilteringComponent = ResourceFilterHOC(
config, localStorageRecentKeyword, localStorageRecentCapacity,
);
interface VisibleTopBarProps {
onApplyFilter(filter: string | null): void;
onApplySorting(sorting: string | null): void;
onApplySearch(search: string | null): void;
query: WebhooksQuery;
onCreateWebhook(): void;
goBackContent: JSX.Element;
}
export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element {
const {
query, onApplyFilter, onApplySorting, onApplySearch, onCreateWebhook, goBackContent,
} = props;
const [visibility, setVisibility] = useState(defaultVisibility);
return (
<>
<Row justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={14}>
{goBackContent}
</Col>
</Row>
<Row className='cvat-webhooks-page-top-bar' justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={14}>
<div className='cvat-webhooks-page-filters-wrapper'>
<Input.Search
enterButton
onSearch={(phrase: string) => {
onApplySearch(phrase);
}}
defaultValue={query.search || ''}
className='cvat-webhooks-page-search-bar'
placeholder='Search ...'
/>
<div>
<SortingComponent
visible={visibility.sorting}
onVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, sorting: visible })
)}
defaultFields={query.sort?.split(',') || ['-ID']}
sortingFields={['ID', 'Target URL', 'Owner', 'Description', 'Type', 'Updated date']}
onApplySorting={onApplySorting}
/>
<FilteringComponent
value={query.filter}
predefinedVisible={visibility.predefined}
builderVisible={visibility.builder}
recentVisible={visibility.recent}
onPredefinedVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, predefined: visible })
)}
onBuilderVisibleChange={(visible: boolean) => (
setVisibility({ ...defaultVisibility, builder: visible })
)}
onRecentVisibleChange={(visible: boolean) => (
setVisibility({
...defaultVisibility,
builder: visibility.builder,
recent: visible,
})
)}
onApplyFilter={onApplyFilter}
/>
</div>
</div>
<div className='cvat-webhooks-add-wrapper'>
<Button onClick={onCreateWebhook} type='primary' className='cvat-create-webhook' icon={<PlusOutlined />} />
</div>
</Col>
</Row>
</>
);
}

@ -0,0 +1,197 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useState } from 'react';
import { useHistory } from 'react-router';
import moment from 'moment';
import { Col, Row } from 'antd/lib/grid';
import Button from 'antd/lib/button';
import Menu from 'antd/lib/menu';
import Dropdown from 'antd/lib/dropdown';
import Text from 'antd/lib/typography/Text';
import { MoreOutlined } from '@ant-design/icons';
import Modal from 'antd/lib/modal';
import { groupEvents } from 'components/setup-webhook-pages/setup-webhook-content';
import Paragraph from 'antd/lib/typography/Paragraph';
import CVATTooltip from 'components/common/cvat-tooltip';
import { deleteWebhookAsync } from 'actions/webhooks-actions';
import { useDispatch } from 'react-redux';
export interface WebhookItemProps {
webhookInstance: any;
}
interface WebhookStatus {
message?: string;
className: string;
}
function setUpWebhookStatus(status: number): WebhookStatus {
if (status && status.toString().startsWith('2')) {
return {
message: `Last delivery was succesful. Response: ${status}`,
className: 'cvat-webhook-status-available',
};
}
if (status && status.toString().startsWith('5')) {
return {
message: `Last delivery was not succesful. Response: ${status}`,
className: 'cvat-webhook-status-failed',
};
}
return {
message: status ? `Response: ${status}` : undefined,
className: 'cvat-webhook-status-unavailable',
};
}
function WebhookItem(props: WebhookItemProps): JSX.Element | null {
const [isRemoved, setIsRemoved] = useState<boolean>(false);
const [pingFetching, setPingFetching] = useState<boolean>(false);
const history = useHistory();
const dispatch = useDispatch();
const { webhookInstance } = props;
const {
id, description, updatedDate, createdDate, owner, targetURL, events,
} = webhookInstance;
const updated = moment(updatedDate).fromNow();
const created = moment(createdDate).format('MMMM Do YYYY');
const username = owner ? owner.username : null;
const { lastStatus } = webhookInstance;
const [webhookStatus, setWebhookStatus] = useState<WebhookStatus>(setUpWebhookStatus(lastStatus));
const eventsList = groupEvents(events).join(', ');
return (
<Row className='cvat-webhooks-list-item' style={isRemoved ? { opacity: 0.5, pointerEvents: 'none' } : {}}>
<Col span={1}>
{
webhookStatus.message ? (
<CVATTooltip title={webhookStatus.message} overlayStyle={{ maxWidth: '300px' }}>
<svg height='24' width='24' className={webhookStatus.className}>
<circle cx='12' cy='12' r='5' strokeWidth='0' />
</svg>
</CVATTooltip>
) : (
<svg height='24' width='24' className={webhookStatus.className}>
<circle cx='12' cy='12' r='5' strokeWidth='0' />
</svg>
)
}
</Col>
<Col span={7}>
<Paragraph ellipsis={{
tooltip: description,
rows: 2,
}}
>
<Text strong type='secondary' className='cvat-item-webhook-id'>{`#${id}: `}</Text>
<Text strong className='cvat-item-webhook-description'>{description}</Text>
</Paragraph>
{username && (
<>
<Text type='secondary'>{`Created by ${username} on ${created}`}</Text>
<br />
</>
)}
<Text type='secondary'>{`Last updated ${updated}`}</Text>
</Col>
<Col span={7}>
<Paragraph ellipsis={{
tooltip: targetURL,
rows: 3,
}}
>
<Text type='secondary' className='cvat-webhook-info-text'>URL:</Text>
{targetURL}
</Paragraph>
</Col>
<Col span={6}>
<Paragraph ellipsis={{
tooltip: eventsList,
rows: 3,
}}
>
<Text type='secondary' className='cvat-webhook-info-text'>Events:</Text>
{eventsList}
</Paragraph>
</Col>
<Col span={3}>
<Row justify='end'>
<Col>
<Button
className='cvat-item-ping-webhook-button'
type='primary'
disabled={pingFetching}
loading={pingFetching}
size='large'
ghost
onClick={(): void => {
setPingFetching(true);
webhookInstance.ping().then((deliveryInstance: any) => {
setWebhookStatus(setUpWebhookStatus(
deliveryInstance.statusCode ? deliveryInstance.statusCode : 'Timeout',
));
}).finally(() => {
setPingFetching(false);
});
}}
>
Ping
</Button>
</Col>
</Row>
<Row justify='end'>
<Col>
<Dropdown overlay={() => (
<Menu>
<Menu.Item key='edit'>
<a
href={`/webhooks/update/${id}`}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
history.push(`/webhooks/update/${id}`);
return false;
}}
>
Edit
</a>
</Menu.Item>
<Menu.Item
key='delete'
onClick={() => {
Modal.confirm({
title: 'Are you sure you want to remove the hook?',
content: 'It will stop notificating the specified URL about listed events',
className: 'cvat-modal-confirm-remove-webhook',
onOk: () => {
dispatch(deleteWebhookAsync(webhookInstance)).then(() => {
setIsRemoved(true);
});
},
});
}}
>
Delete
</Menu.Item>
</Menu>
)}
>
<div className='cvat-webhooks-page-actions-button'>
<Text className='cvat-text-color'>Actions</Text>
<MoreOutlined className='cvat-menu-icon' />
</div>
</Dropdown>
</Col>
</Row>
</Col>
</Row>
);
}
export default React.memo(WebhookItem);

@ -0,0 +1,55 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { Config } from 'react-awesome-query-builder';
export const config: Partial<Config> = {
fields: {
description: {
label: 'Description',
type: 'text',
valueSources: ['value'],
operators: ['like'],
},
target_url: {
label: 'Target URL',
type: 'text',
valueSources: ['value'],
operators: ['like'],
},
owner: {
label: 'Owner',
type: 'text',
valueSources: ['value'],
operators: ['equal'],
},
updated_date: {
label: 'Last updated',
type: 'datetime',
operators: ['between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
},
type: {
label: 'Type',
type: 'select',
valueSources: ['value'],
fieldSettings: {
listValues: [
{ value: 'organization', title: 'Organization' },
{ value: 'project', title: 'Project' },
],
},
},
id: {
label: 'ID',
type: 'number',
operators: ['equal', 'between', 'greater', 'greater_or_equal', 'less', 'less_or_equal'],
fieldSettings: { min: 0 },
valueSources: ['value'],
},
},
};
export const localStorageRecentCapacity = 10;
export const localStorageRecentKeyword = 'recentlyAppliedWebhooksFilters';
export const predefinedFilterValues = {};

@ -0,0 +1,29 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { Row, Col } from 'antd/lib/grid';
import { useSelector } from 'react-redux';
import { CombinedState } from 'reducers';
import WebhookItem from './webhook-item';
function WebhooksList(): JSX.Element {
const webhooks = useSelector((state: CombinedState) => state.webhooks.current);
return (
<Row justify='center' align='middle'>
<Col className='cvat-webhooks-list' md={22} lg={18} xl={16} xxl={14}>
{webhooks.map(
(webhook: any): JSX.Element => (
<WebhookItem
key={webhook.id}
webhookInstance={webhook}
/>
),
)}
</Col>
</Row>
);
}
export default React.memo(WebhooksList);

@ -0,0 +1,151 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
useHistory, useRouteMatch,
} from 'react-router';
import Spin from 'antd/lib/spin';
import { Row, Col } from 'antd/lib/grid';
import Pagination from 'antd/lib/pagination';
import Button from 'antd/lib/button';
import { CombinedState, Indexable } from 'reducers';
import { updateHistoryFromQuery } from 'components/resource-sorting-filtering';
import { getWebhooksAsync } from 'actions/webhooks-actions';
import { LeftOutlined } from '@ant-design/icons';
import WebhooksList from './webhooks-list';
import TopBar from './top-bar';
import EmptyWebhooksListComponent from './empty-list';
interface ProjectRouteMatch {
id?: string | undefined;
}
const PAGE_SIZE = 10;
function WebhooksPage(): JSX.Element | null {
const dispatch = useDispatch();
const history = useHistory();
const organization = useSelector((state: CombinedState) => state.organizations.current);
const fetching = useSelector((state: CombinedState) => state.webhooks.fetching);
const totalCount = useSelector((state: CombinedState) => state.webhooks.totalCount);
const query = useSelector((state: CombinedState) => state.webhooks.query);
const projectsMatch = useRouteMatch<ProjectRouteMatch>({ path: '/projects/:id/webhooks' });
const [onCreateParams, setOnCreateParams] = useState<string | null>(null);
const onCreateWebhook = useCallback(() => {
history.push(`/webhooks/create?${onCreateParams || ''}`);
}, [onCreateParams]);
const goBackContent = (
<Button
className='cvat-webhooks-go-back'
onClick={() => history.push(projectsMatch ? `/projects/${projectsMatch.params.id}` : '/organization')}
type='link'
size='large'
>
<LeftOutlined />
{projectsMatch ? 'Back to project' : 'Back to organization'}
</Button>
);
const queryParams = new URLSearchParams(history.location.search);
const updatedQuery = { ...query };
for (const key of Object.keys(updatedQuery)) {
(updatedQuery as Indexable)[key] = queryParams.get(key) || null;
if (key === 'page') {
updatedQuery.page = updatedQuery.page ? +updatedQuery.page : 1;
}
}
useEffect(() => {
if (projectsMatch) {
const { id } = projectsMatch.params;
setOnCreateParams(`projectId=${id}`);
dispatch(getWebhooksAsync({ ...updatedQuery, projectId: +id }));
} else if (organization) {
dispatch(getWebhooksAsync(updatedQuery));
} else {
history.push('/');
}
}, [organization]);
useEffect(() => {
history.replace({
search: updateHistoryFromQuery(query),
});
}, [query]);
const content = totalCount ? (
<>
<WebhooksList />
<Row justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={14}>
<Pagination
className='cvat-tasks-pagination'
onChange={(page: number) => {
dispatch(getWebhooksAsync({
...query,
page,
}));
}}
showSizeChanger={false}
total={totalCount}
current={query.page}
pageSize={PAGE_SIZE}
showQuickJumper
/>
</Col>
</Row>
</>
) : <EmptyWebhooksListComponent query={query} />;
return (
<div className='cvat-webhooks-page'>
<TopBar
query={updatedQuery}
onCreateWebhook={onCreateWebhook}
goBackContent={goBackContent}
onApplySearch={(search: string | null) => {
dispatch(
getWebhooksAsync({
...query,
search,
page: 1,
}),
);
}}
onApplyFilter={(filter: string | null) => {
dispatch(
getWebhooksAsync({
...query,
filter,
page: 1,
}),
);
}}
onApplySorting={(sorting: string | null) => {
dispatch(
getWebhooksAsync({
...query,
sort: sorting,
page: 1,
}),
);
}}
/>
{ fetching ? (
<div className='cvat-empty-webhooks-list'>
<Spin size='large' className='cvat-spinner' />
</div>
) : content }
</div>
);
}
export default React.memo(WebhooksPage);

@ -4,6 +4,7 @@
import _cvat from 'cvat-core/src/api'; import _cvat from 'cvat-core/src/api';
import ObjectState from 'cvat-core/src/object-state'; import ObjectState from 'cvat-core/src/object-state';
import Webhook from 'cvat-core/src/webhook';
import { import {
Label, Attribute, RawAttribute, RawLabel, Label, Attribute, RawAttribute, RawLabel,
} from 'cvat-core/src/labels'; } from 'cvat-core/src/labels';
@ -28,6 +29,7 @@ export {
Attribute, Attribute,
ShapeType, ShapeType,
Storage, Storage,
Webhook,
}; };
export type { export type {

@ -6,6 +6,7 @@
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { Canvas3d } from 'cvat-canvas3d/src/typescript/canvas3d'; import { Canvas3d } from 'cvat-canvas3d/src/typescript/canvas3d';
import { Canvas, RectDrawingMethod, CuboidDrawingMethod } from 'cvat-canvas-wrapper'; import { Canvas, RectDrawingMethod, CuboidDrawingMethod } from 'cvat-canvas-wrapper';
import { Webhook } from 'cvat-core-wrapper';
import { IntelligentScissors } from 'utils/opencv-wrapper/intelligent-scissors'; import { IntelligentScissors } from 'utils/opencv-wrapper/intelligent-scissors';
import { KeyMap } from 'utils/mousetrap-react'; import { KeyMap } from 'utils/mousetrap-react';
import { OpenCVTracker } from 'utils/opencv-wrapper/opencv-interfaces'; import { OpenCVTracker } from 'utils/opencv-wrapper/opencv-interfaces';
@ -510,6 +511,12 @@ export interface NotificationsState {
updatingMembership: null | ErrorState; updatingMembership: null | ErrorState;
removingMembership: null | ErrorState; removingMembership: null | ErrorState;
}; };
webhooks: {
fetching: null | ErrorState;
creating: null | ErrorState;
updating: null | ErrorState;
deleting: null | ErrorState;
};
}; };
messages: { messages: {
tasks: { tasks: {
@ -837,6 +844,22 @@ export interface OrganizationState {
updatingMember: boolean; updatingMember: boolean;
} }
export interface WebhooksQuery {
page: number;
id: number | null;
search: string | null;
filter: string | null;
sort: string | null;
projectId: number | null;
}
export interface WebhooksState {
current: Webhook[],
totalCount: number;
fetching: boolean;
query: WebhooksQuery;
}
export interface CombinedState { export interface CombinedState {
auth: AuthState; auth: AuthState;
projects: ProjectsState; projects: ProjectsState;
@ -857,6 +880,7 @@ export interface CombinedState {
import: ImportState; import: ImportState;
cloudStorages: CloudStoragesState; cloudStorages: CloudStoragesState;
organizations: OrganizationState; organizations: OrganizationState;
webhooks: WebhooksState;
} }
export enum DimensionType { export enum DimensionType {

@ -22,6 +22,7 @@ import { ImportActionTypes } from 'actions/import-actions';
import { CloudStorageActionTypes } from 'actions/cloud-storage-actions'; import { CloudStorageActionTypes } from 'actions/cloud-storage-actions';
import { OrganizationActionsTypes } from 'actions/organization-actions'; import { OrganizationActionsTypes } from 'actions/organization-actions';
import { JobsActionTypes } from 'actions/jobs-actions'; import { JobsActionTypes } from 'actions/jobs-actions';
import { WebhooksActionsTypes } from 'actions/webhooks-actions';
import { NotificationsState } from '.'; import { NotificationsState } from '.';
@ -150,6 +151,12 @@ const defaultState: NotificationsState = {
updatingMembership: null, updatingMembership: null,
removingMembership: null, removingMembership: null,
}, },
webhooks: {
fetching: null,
creating: null,
updating: null,
deleting: null,
},
}, },
messages: { messages: {
tasks: { tasks: {
@ -1621,6 +1628,70 @@ export default function (state = defaultState, action: AnyAction): Notifications
}, },
}; };
} }
case WebhooksActionsTypes.GET_WEBHOOKS_FAILED: {
return {
...state,
errors: {
...state.errors,
webhooks: {
...state.errors.webhooks,
fetching: {
message: 'Could not fetch a list of webhooks',
reason: action.payload.error.toString(),
className: 'cvat-notification-notice-get-webhooks-failed',
},
},
},
};
}
case WebhooksActionsTypes.CREATE_WEBHOOK_FAILED: {
return {
...state,
errors: {
...state.errors,
webhooks: {
...state.errors.webhooks,
creating: {
message: 'Could not create webhook',
reason: action.payload.error.toString(),
className: 'cvat-notification-notice-create-webhook-failed',
},
},
},
};
}
case WebhooksActionsTypes.UPDATE_WEBHOOK_FAILED: {
return {
...state,
errors: {
...state.errors,
webhooks: {
...state.errors.webhooks,
updating: {
message: 'Could not update webhook',
reason: action.payload.error.toString(),
className: 'cvat-notification-notice-update-webhook-failed',
},
},
},
};
}
case WebhooksActionsTypes.DELETE_WEBHOOK_FAILED: {
return {
...state,
errors: {
...state.errors,
webhooks: {
...state.errors.webhooks,
deleting: {
message: 'Could not delete webhook',
reason: action.payload.error.toString(),
className: 'cvat-notification-notice-delete-webhook-failed',
},
},
},
};
}
case BoundariesActionTypes.RESET_AFTER_ERROR: case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: { case AuthActionTypes.LOGOUT_SUCCESS: {
return { ...defaultState }; return { ...defaultState };

@ -23,6 +23,7 @@ import exportReducer from './export-reducer';
import importReducer from './import-reducer'; import importReducer from './import-reducer';
import cloudStoragesReducer from './cloud-storages-reducer'; import cloudStoragesReducer from './cloud-storages-reducer';
import organizationsReducer from './organizations-reducer'; import organizationsReducer from './organizations-reducer';
import webhooksReducer from './webhooks-reducer';
export default function createRootReducer(): Reducer { export default function createRootReducer(): Reducer {
return combineReducers({ return combineReducers({
@ -45,5 +46,6 @@ export default function createRootReducer(): Reducer {
import: importReducer, import: importReducer,
cloudStorages: cloudStoragesReducer, cloudStorages: cloudStoragesReducer,
organizations: organizationsReducer, organizations: organizationsReducer,
webhooks: webhooksReducer,
}); });
} }

@ -0,0 +1,56 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
import { AuthActions, AuthActionTypes } from 'actions/auth-actions';
import { WebhooksActions, WebhooksActionsTypes } from 'actions/webhooks-actions';
import { WebhooksState } from 'reducers';
const defaultState: WebhooksState = {
current: [],
totalCount: 0,
query: {
page: 1,
id: null,
projectId: null,
search: null,
filter: null,
sort: null,
},
fetching: false,
};
export default function (
state: WebhooksState = defaultState,
action: WebhooksActions | AuthActions,
): WebhooksState {
switch (action.type) {
case WebhooksActionsTypes.GET_WEBHOOKS: {
return {
...state,
fetching: true,
query: {
...state.query,
...action.payload.query,
},
};
}
case WebhooksActionsTypes.GET_WEBHOOKS_SUCCESS:
return {
...state,
fetching: false,
totalCount: action.payload.count,
current: action.payload.webhooks,
};
case WebhooksActionsTypes.GET_WEBHOOKS_FAILED:
return {
...state,
fetching: false,
};
case AuthActionTypes.LOGOUT_SUCCESS: {
return { ...defaultState };
}
default:
return state;
}
}

@ -1,4 +1,5 @@
# Copyright (C) 2021-2022 Intel Corporation # Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) 2022 CVAT.ai Corporation
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
@ -15,6 +16,7 @@ from rest_framework.response import Response
from cvat.apps.engine.models import Location from cvat.apps.engine.models import Location
from cvat.apps.engine.location import StorageType, get_location_configuration from cvat.apps.engine.location import StorageType, get_location_configuration
from cvat.apps.engine.serializers import DataSerializer, LabeledDataSerializer from cvat.apps.engine.serializers import DataSerializer, LabeledDataSerializer
from cvat.apps.webhooks.signals import signal_update, signal_create, signal_delete
class TusFile: class TusFile:
_tus_cache_timeout = 3600 _tus_cache_timeout = 3600
@ -321,6 +323,12 @@ class SerializeMixin:
return import_func(request, filename=file_name) return import_func(request, filename=file_name)
return self.upload_data(request) return self.upload_data(request)
class CreateModelMixin(mixins.CreateModelMixin):
def perform_create(self, serializer):
super().perform_create(serializer)
signal_create.send(self, instance=serializer.instance)
class PartialUpdateModelMixin: class PartialUpdateModelMixin:
""" """
Update fields of a model instance. Update fields of a model instance.
@ -329,8 +337,23 @@ class PartialUpdateModelMixin:
""" """
def perform_update(self, serializer): def perform_update(self, serializer):
old_values = {
attr: serializer.to_representation(serializer.instance).get(attr, None)
for attr in self.request.data.keys()
}
mixins.UpdateModelMixin.perform_update(self, serializer=serializer) mixins.UpdateModelMixin.perform_update(self, serializer=serializer)
if getattr(serializer.instance, '_prefetched_objects_cache', None):
serializer.instance._prefetched_objects_cache = {}
signal_update.send(self, instance=serializer.instance, old_values=old_values)
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
kwargs['partial'] = True kwargs['partial'] = True
return mixins.UpdateModelMixin.update(self, request=request, *args, **kwargs) return mixins.UpdateModelMixin.update(self, request=request, *args, **kwargs)
class DestroyModelMixin(mixins.DestroyModelMixin):
def perform_destroy(self, instance):
signal_delete.send(self, instance=instance)
super().perform_destroy(instance)

@ -468,6 +468,9 @@ class Job(models.Model):
project = self.segment.task.project project = self.segment.task.project
return project.id if project else None return project.id if project else None
def get_organization_id(self):
return self.segment.task.organization
def get_bug_tracker(self): def get_bug_tracker(self):
task = self.segment.task task = self.segment.task
project = task.project project = task.project
@ -675,6 +678,12 @@ class Issue(models.Model):
updated_date = models.DateTimeField(null=True, blank=True) updated_date = models.DateTimeField(null=True, blank=True)
resolved = models.BooleanField(default=False) resolved = models.BooleanField(default=False)
def get_project_id(self):
return self.job.get_project_id()
def get_organization_id(self):
return self.job.get_organization_id()
class Comment(models.Model): class Comment(models.Model):
issue = models.ForeignKey(Issue, related_name='comments', on_delete=models.CASCADE) issue = models.ForeignKey(Issue, related_name='comments', on_delete=models.CASCADE)
owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
@ -682,6 +691,12 @@ class Comment(models.Model):
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
def get_project_id(self):
return self.issue.get_project_id()
def get_organization_id(self):
return self.issue.get_organization_id()
class CloudProviderChoice(str, Enum): class CloudProviderChoice(str, Enum):
AWS_S3 = 'AWS_S3_BUCKET' AWS_S3 = 'AWS_S3_BUCKET'
AZURE_CONTAINER = 'AZURE_CONTAINER' AZURE_CONTAINER = 'AZURE_CONTAINER'

@ -41,6 +41,7 @@ from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from django_sendfile import sendfile from django_sendfile import sendfile
from cvat.apps.webhooks.signals import signal_create, signal_update
import cvat.apps.dataset_manager as dm import cvat.apps.dataset_manager as dm
import cvat.apps.dataset_manager.views # pylint: disable=unused-import import cvat.apps.dataset_manager.views # pylint: disable=unused-import
from cvat.apps.engine.cloud_provider import ( from cvat.apps.engine.cloud_provider import (
@ -72,7 +73,7 @@ from cvat.apps.engine.serializers import (
from utils.dataset_manifest import ImageManifestManager from utils.dataset_manifest import ImageManifestManager
from cvat.apps.engine.utils import av_scan_paths, process_failed_job, configure_dependent_job from cvat.apps.engine.utils import av_scan_paths, process_failed_job, configure_dependent_job
from cvat.apps.engine import backup from cvat.apps.engine import backup
from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, DestroyModelMixin
from cvat.apps.engine.location import get_location_configuration, StorageType from cvat.apps.engine.location import get_location_configuration, StorageType
from . import models, task from . import models, task
@ -270,7 +271,7 @@ class ServerViewSet(viewsets.ViewSet):
}) })
) )
class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin,
PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
): ):
queryset = models.Project.objects.prefetch_related(Prefetch('label_set', queryset = models.Project.objects.prefetch_related(Prefetch('label_set',
@ -307,6 +308,7 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(owner=self.request.user, serializer.save(owner=self.request.user,
organization=self.request.iam_context['organization']) organization=self.request.iam_context['organization'])
signal_create.send(self, instance=serializer.instance)
@extend_schema( @extend_schema(
summary='Method returns information of the tasks of the project with the selected id', summary='Method returns information of the tasks of the project with the selected id',
@ -710,7 +712,7 @@ class DataChunkGetter:
}) })
) )
class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin,
PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
): ):
queryset = Task.objects.prefetch_related( queryset = Task.objects.prefetch_related(
@ -798,12 +800,25 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
def perform_update(self, serializer): def perform_update(self, serializer):
instance = serializer.instance instance = serializer.instance
old_values = {}
old_repr = serializer.to_representation(instance)
for attr in self.request.data.keys():
old_values[attr] = old_repr[attr] if attr in old_repr \
else getattr(instance, attr, None)
updated_instance = serializer.save() updated_instance = serializer.save()
if instance.project: if instance.project:
instance.project.save() instance.project.save()
if updated_instance.project: if updated_instance.project:
updated_instance.project.save() updated_instance.project.save()
if getattr(instance, '_prefetched_objects_cache', None):
instance._prefetched_objects_cache = {}
signal_update.send(self, instance=serializer.instance, old_values=old_values)
def perform_create(self, serializer): def perform_create(self, serializer):
instance = serializer.save(owner=self.request.user, instance = serializer.save(owner=self.request.user,
organization=self.request.iam_context['organization']) organization=self.request.iam_context['organization'])
@ -811,6 +826,7 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
db_project = instance.project db_project = instance.project
db_project.save() db_project.save()
assert instance.organization == db_project.organization assert instance.organization == db_project.organization
signal_create.send(self, instance=serializer.instance)
def perform_destroy(self, instance): def perform_destroy(self, instance):
task_dirname = instance.get_dirname() task_dirname = instance.get_dirname()
@ -823,6 +839,7 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
db_project = instance.project db_project = instance.project
db_project.save() db_project.save()
@extend_schema(summary='Method returns a list of jobs for a specific task', @extend_schema(summary='Method returns a list of jobs for a specific task',
responses=JobReadSerializer(many=True)) # Duplicate to still get 'list' op. name responses=JobReadSerializer(many=True)) # Duplicate to still get 'list' op. name
@action(detail=True, methods=['GET'], serializer_class=JobReadSerializer(many=True), @action(detail=True, methods=['GET'], serializer_class=JobReadSerializer(many=True),
@ -1472,6 +1489,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
return Response(data=str(e), status=status.HTTP_400_BAD_REQUEST) return Response(data=str(e), status=status.HTTP_400_BAD_REQUEST)
return Response(data) return Response(data)
@extend_schema(methods=['PATCH'], @extend_schema(methods=['PATCH'],
operation_id='jobs_partial_update_annotations_file', operation_id='jobs_partial_update_annotations_file',
summary="Allows to upload an annotation file chunk. " summary="Allows to upload an annotation file chunk. "
@ -1487,6 +1505,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
self._object = self.get_object() self._object = self.get_object()
return self.append_tus_chunk(request, file_id) return self.append_tus_chunk(request, file_id)
@extend_schema(summary='Export job as a dataset in a specific format', @extend_schema(summary='Export job as a dataset in a specific format',
parameters=[ parameters=[
OpenApiParameter('format', location=OpenApiParameter.QUERY, OpenApiParameter('format', location=OpenApiParameter.QUERY,
@ -1539,6 +1558,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
return Response(serializer.data) return Response(serializer.data)
@extend_schema(summary='Method returns data for a specific job', @extend_schema(summary='Method returns data for a specific job',
parameters=[ parameters=[
OpenApiParameter('type', description='Specifies the type of the requested data', OpenApiParameter('type', description='Specifies the type of the requested data',
@ -1566,6 +1586,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
return data_getter(request, db_job.segment.start_frame, return data_getter(request, db_job.segment.start_frame,
db_job.segment.stop_frame, db_job.segment.task.data, db_job) db_job.segment.stop_frame, db_job.segment.task.data, db_job)
@extend_schema(summary='Method provides a meta information about media files which are related with the job', @extend_schema(summary='Method provides a meta information about media files which are related with the job',
responses={ responses={
'200': DataMetaReadSerializer, '200': DataMetaReadSerializer,
@ -1685,7 +1706,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
}) })
) )
class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin,
PartialUpdateModelMixin PartialUpdateModelMixin
): ):
queryset = Issue.objects.all().order_by('-id') queryset = Issue.objects.all().order_by('-id')
@ -1717,6 +1738,7 @@ class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(owner=self.request.user) serializer.save(owner=self.request.user)
signal_create.send(self, instance=serializer.instance)
@extend_schema(summary='The action returns all comments of a specific issue', @extend_schema(summary='The action returns all comments of a specific issue',
responses=CommentReadSerializer(many=True)) # Duplicate to still get 'list' op. name responses=CommentReadSerializer(many=True)) # Duplicate to still get 'list' op. name
@ -1765,7 +1787,7 @@ class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
}) })
) )
class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin,
PartialUpdateModelMixin PartialUpdateModelMixin
): ):
queryset = Comment.objects.all().order_by('-id') queryset = Comment.objects.all().order_by('-id')
@ -1792,6 +1814,7 @@ class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(owner=self.request.user) serializer.save(owner=self.request.user)
signal_create.send(self, instance=serializer.instance)
@extend_schema(tags=['users']) @extend_schema(tags=['users'])
@extend_schema_view( @extend_schema_view(

@ -1,4 +1,5 @@
# Copyright (C) 2022 Intel Corporation # Copyright (C) 2022 Intel Corporation
# Copyright (C) 2022 CVAT.ai Corporation
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
@ -12,6 +13,7 @@ from django.conf import settings
from django.db.models import Q from django.db.models import Q
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
from cvat.apps.webhooks.models import Webhook
from cvat.apps.organizations.models import Membership, Organization from cvat.apps.organizations.models import Membership, Organization
from cvat.apps.engine.models import Project, Task, Job, Issue from cvat.apps.engine.models import Project, Task, Job, Issue
@ -763,6 +765,99 @@ class TaskPermission(OpenPolicyAgentPermission):
return data return data
class WebhookPermission(OpenPolicyAgentPermission):
@classmethod
def create(cls, request, view, obj):
permissions = []
if view.basename == 'webhook':
project_id = request.data.get('project_id')
for scope in cls.get_scopes(request, view, obj):
self = cls.create_base_perm(request, view, scope, obj,
project_id=project_id)
permissions.append(self)
owner = request.data.get('owner_id') or request.data.get('owner')
if owner:
perm = UserPermission.create_scope_view(request, owner)
permissions.append(perm)
if project_id:
perm = ProjectPermission.create_scope_view(request, project_id)
permissions.append(perm)
return permissions
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.url = settings.IAM_OPA_DATA_URL + '/webhooks/allow'
@staticmethod
def get_scopes(request, view, obj):
scope = {
('create', 'POST'): 'create',
('destroy', 'DELETE'): 'delete',
('partial_update', 'PATCH'): 'update',
('update', 'PUT'): 'update',
('list', 'GET'): 'list',
('retrieve', 'GET'): 'view',
}.get((view.action, request.method))
scopes = []
if scope == 'create':
webhook_type = request.data.get('type')
if webhook_type:
scope += f'@{webhook_type}'
scopes.append(scope)
elif scope in ['update', 'delete', 'list', 'view']:
scopes.append(scope)
return scopes
def get_resource(self):
data = None
if self.obj:
data = {
"id": self.obj.id,
"owner": {"id": getattr(self.obj.owner, 'id', None) },
'organization': {
"id": getattr(self.obj.organization, 'id', None)
},
"project": None
}
if self.obj.type == 'project' and getattr(self.obj, 'project', None):
data['project'] = {
'owner': {'id': getattr(self.obj.project.owner, 'id', None)}
}
elif self.scope in ['create@project', 'create@organization']:
project = None
if self.project_id:
try:
project = Project.objects.get(id=self.project_id)
except Project.DoesNotExist:
raise ValidationError(f"Could not find project with provided id: {self.project_id}")
num_resources = Webhook.objects.filter(project=self.project_id).count() if project \
else Webhook.objects.filter(organization=self.org_id, project=None).count()
data = {
'id': None,
'owner': self.user_id,
'organization': {
'id': self.org_id
},
'num_resources': num_resources
}
data['project'] = None if project is None else {
'owner': {
'id': getattr(project.owner, 'id', None)
},
}
return data
class JobPermission(OpenPolicyAgentPermission): class JobPermission(OpenPolicyAgentPermission):
@classmethod @classmethod
def create(cls, request, view, obj): def create(cls, request, view, obj):
@ -1029,6 +1124,7 @@ class IssuePermission(OpenPolicyAgentPermission):
return data return data
class PolicyEnforcer(BasePermission): class PolicyEnforcer(BasePermission):
# pylint: disable=no-self-use # pylint: disable=no-self-use
def check_permission(self, request, view, obj): def check_permission(self, request, view, obj):
@ -1071,3 +1167,4 @@ class IsMemberInOrganization(BasePermission):
return membership is not None return membership is not None
return True return True

@ -35,6 +35,7 @@ UPDATE_OWNER := "update:owner"
EXPORT_ANNOTATIONS := "export:annotations" EXPORT_ANNOTATIONS := "export:annotations"
EXPORT_DATASET := "export:dataset" EXPORT_DATASET := "export:dataset"
CREATE_IN_PROJECT := "create@project" CREATE_IN_PROJECT := "create@project"
CREATE_IN_ORGANIZATION := "create@organization"
UPDATE_PROJECT := "update:project" UPDATE_PROJECT := "update:project"
VIEW_ANNOTATIONS := "view:annotations" VIEW_ANNOTATIONS := "view:annotations"
UPDATE_ANNOTATIONS := "update:annotations" UPDATE_ANNOTATIONS := "update:annotations"

@ -0,0 +1,21 @@
Scope,Resource,Context,Ownership,Limit,Method,URL,Privilege,Membership
create@project,Webhook,Sandbox,N/A,,POST,/webhooks,Admin,N/A
create@project,Webhook,Sandbox,Project:owner,resource['num_resources'] < 10,POST,/webhooks,Worker,N/A
create@project,Webhook,Organization,N/A,resource['num_resources'] < 10,POST,/webhooks,Worker,Maintainer
create@project,Webhook,Organization,Project:owner,resource['num_resources'] < 10,POST,/webhooks,Worker,Worker
create@organization,Webhook,Organization,N/A,,POST,/webhooks,Admin,N/A
create@organization,Webhook,Organization,N/A,resource['num_resources'] < 10,POST,/webhooks,Worker,Maintainer
update,Webhook,Sandbox,N/A,,PATCH,/webhooks/{id},Admin,N/A
update,Webhook,Sandbox,"Project:owner, owner",,PATCH,/webhooks/{id},Worker,N/A
update,Webhook,Organization,N/A,,PATCH,/webhooks/{id},Worker,Maintainer
update,Webhook,Organization,"Project:owner, owner",,PATCH,/webhooks/{id},Worker,Worker
delete,Webhook,Sandbox,N/A,,DELETE,/webhooks/{id},Admin,N/A
delete,Webhook,Sandbox,"Project:owner, owner",,DELETE,/webhooks/{id},Worker,N/A
delete,Webhook,Organization,N/A,,DELETE,/webhooks/{id},Worker,Maintainer
delete,Webhook,Organization,"Project:owner, owner",,DELETE,/webhooks/{id},Worker,Worker
view,Webhook,Sandbox,N/A,,GET,/webhooks/{id},Admin,N/A
view,Webhook,Sandbox,"Project:owner, owner",,GET,/webhooks/{id},None,N/A
view,Webhook,Organization,N/A,,GET,/webhooks/{id},Worker,Maintainer
view,Webhook,Organization,"Project:owner, owner",,GET,/webhooks/{id},None,Worker
list,N/A,Sandbox,N/A,,GET,/webhooks,None,N/A
list,N/A,Organization,N/A,,GET,/webhooks,None,Worker
1 Scope Resource Context Ownership Limit Method URL Privilege Membership
2 create@project Webhook Sandbox N/A POST /webhooks Admin N/A
3 create@project Webhook Sandbox Project:owner resource['num_resources'] < 10 POST /webhooks Worker N/A
4 create@project Webhook Organization N/A resource['num_resources'] < 10 POST /webhooks Worker Maintainer
5 create@project Webhook Organization Project:owner resource['num_resources'] < 10 POST /webhooks Worker Worker
6 create@organization Webhook Organization N/A POST /webhooks Admin N/A
7 create@organization Webhook Organization N/A resource['num_resources'] < 10 POST /webhooks Worker Maintainer
8 update Webhook Sandbox N/A PATCH /webhooks/{id} Admin N/A
9 update Webhook Sandbox Project:owner, owner PATCH /webhooks/{id} Worker N/A
10 update Webhook Organization N/A PATCH /webhooks/{id} Worker Maintainer
11 update Webhook Organization Project:owner, owner PATCH /webhooks/{id} Worker Worker
12 delete Webhook Sandbox N/A DELETE /webhooks/{id} Admin N/A
13 delete Webhook Sandbox Project:owner, owner DELETE /webhooks/{id} Worker N/A
14 delete Webhook Organization N/A DELETE /webhooks/{id} Worker Maintainer
15 delete Webhook Organization Project:owner, owner DELETE /webhooks/{id} Worker Worker
16 view Webhook Sandbox N/A GET /webhooks/{id} Admin N/A
17 view Webhook Sandbox Project:owner, owner GET /webhooks/{id} None N/A
18 view Webhook Organization N/A GET /webhooks/{id} Worker Maintainer
19 view Webhook Organization Project:owner, owner GET /webhooks/{id} None Worker
20 list N/A Sandbox N/A GET /webhooks None N/A
21 list N/A Organization N/A GET /webhooks None Worker

@ -0,0 +1,173 @@
package webhooks
import data.utils
import data.organizations
# input : {
# "scope": <"create@project" | "create@organization" | "update" | "delete" |
# "list" | "view"> or null,
# "auth": {
# "user": {
# "id": <num>
# "privilege": <"admin"|"business"|"user"|"worker"> or null
# }
# "organization": {
# "id": <num>,
# "owner":
# "id": <num>
# },
# "user": {
# "role": <"owner"|"maintainer"|"supervisor"|"worker"> or null
# }
# } or null,
# },
# "resource": {
# "id": <num>,
# "owner": { "id": <num> },
# "organization": { "id": <num> } or null,
# "project": {
# "owner": { "id": num },
# } or null,
# "num_resources": <num>
# }
# }
#
is_project_owner {
input.resource.project.owner.id == input.auth.user.id
}
is_webhook_owner {
input.resource.owner.id == input.auth.user.id
}
default allow = false
allow {
utils.is_admin
}
allow {
input.scope == utils.CREATE_IN_PROJECT
utils.is_sandbox
utils.has_perm(utils.USER)
is_project_owner
input.resource.num_resources < 10
}
allow {
input.scope == utils.LIST
utils.is_sandbox
}
allow {
input.scope == utils.LIST
organizations.is_member
}
filter = [] { # Django Q object to filter list of entries
utils.is_admin
utils.is_sandbox
} else = qobject {
utils.is_admin
utils.is_organization
qobject := [ {"organization": input.auth.organization.id} ]
} else = qobject {
utils.is_sandbox
user := input.auth.user
qobject := [ {"owner_id": user.id}, {"project__owner_id": user.id}, "|" ]
} else = qobject {
utils.is_organization
utils.has_perm(utils.WORKER)
organizations.has_perm(organizations.MAINTAINER)
qobject := [ {"organization": input.auth.organization.id} ]
} else = qobject {
utils.is_organization
utils.has_perm(utils.WORKER)
organizations.has_perm(organizations.WORKER)
user := input.auth.user
qobject := [ {"owner_id": user.id}, {"project__owner_id": user.id},
"|", {"organization": input.auth.organization.id}, "&"]
}
allow {
input.scope == utils.VIEW
utils.is_sandbox
utils.is_resource_owner
}
allow {
input.scope == utils.VIEW
utils.is_sandbox
is_project_owner
}
allow {
{ utils.UPDATE, utils.DELETE }[input.scope]
utils.is_sandbox
utils.has_perm(utils.WORKER)
utils.is_resource_owner
}
allow {
{ utils.UPDATE, utils.DELETE }[input.scope]
utils.is_sandbox
utils.has_perm(utils.WORKER)
is_project_owner
}
allow {
input.scope == utils.VIEW
input.auth.organization.id == input.resource.organization.id
organizations.has_perm(organizations.WORKER)
utils.is_resource_owner
}
allow {
input.scope == utils.VIEW
input.auth.organization.id == input.resource.organization.id
organizations.has_perm(organizations.WORKER)
is_project_owner
}
allow {
{ utils.UPDATE, utils.DELETE }[input.scope]
input.auth.organization.id == input.resource.organization.id
utils.has_perm(utils.WORKER)
organizations.has_perm(organizations.WORKER)
utils.is_resource_owner
}
allow {
{ utils.UPDATE, utils.DELETE, utils.VIEW }[input.scope]
input.auth.organization.id == input.resource.organization.id
utils.has_perm(utils.WORKER)
organizations.has_perm(organizations.MAINTAINER)
}
allow {
{ utils.CREATE_IN_PROJECT, utils.CREATE_IN_ORGANIZATION }[input.scope]
input.auth.organization.id == input.resource.organization.id
utils.has_perm(utils.WORKER)
organizations.has_perm(organizations.MAINTAINER)
input.resource.num_resources < 10
}
allow {
{ utils.UPDATE, utils.DELETE }[input.scope]
input.auth.organization.id == input.resource.organization.id
utils.has_perm(utils.WORKER)
organizations.has_perm(organizations.WORKER)
is_project_owner
}
allow {
{ utils.CREATE_IN_PROJECT }[input.scope]
input.auth.organization.id == input.resource.organization.id
utils.has_perm(utils.WORKER)
organizations.has_perm(organizations.WORKER)
input.resource.num_resources < 10
is_project_owner
}

File diff suppressed because it is too large Load Diff

@ -1,3 +1,8 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from distutils.util import strtobool from distutils.util import strtobool
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
@ -51,6 +56,9 @@ class Invitation(models.Model):
owner = models.ForeignKey(get_user_model(), null=True, on_delete=models.SET_NULL) owner = models.ForeignKey(get_user_model(), null=True, on_delete=models.SET_NULL)
membership = models.OneToOneField(Membership, on_delete=models.CASCADE) membership = models.OneToOneField(Membership, on_delete=models.CASCADE)
def get_organization_id(self):
return self.membership.organization_id
def send(self): def send(self):
if not strtobool(settings.ORG_INVITATION_CONFIRM): if not strtobool(settings.ORG_INVITATION_CONFIRM):
self.accept(self.created_date) self.accept(self.created_date)

@ -1,4 +1,5 @@
# Copyright (C) 2021-2022 Intel Corporation # Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) 2022 CVAT.ai Corporation
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
@ -7,6 +8,8 @@ from rest_framework.permissions import SAFE_METHODS
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
from cvat.apps.engine.mixins import PartialUpdateModelMixin, DestroyModelMixin
from cvat.apps.webhooks.signals import signal_create
from cvat.apps.iam.permissions import ( from cvat.apps.iam.permissions import (
InvitationPermission, MembershipPermission, OrganizationPermission) InvitationPermission, MembershipPermission, OrganizationPermission)
@ -25,7 +28,7 @@ from .serializers import (
'200': OrganizationReadSerializer, '200': OrganizationReadSerializer,
}), }),
list=extend_schema( list=extend_schema(
summary='Method returns a paginated list of organizatins according to query parameters', summary='Method returns a paginated list of organizations according to query parameters',
responses={ responses={
'200': OrganizationReadSerializer(many=True), '200': OrganizationReadSerializer(many=True),
}), }),
@ -50,7 +53,13 @@ from .serializers import (
'204': OpenApiResponse(description='The organization has been deleted'), '204': OpenApiResponse(description='The organization has been deleted'),
}) })
) )
class OrganizationViewSet(viewsets.ModelViewSet): class OrganizationViewSet(viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
PartialUpdateModelMixin,
):
queryset = Organization.objects.all() queryset = Organization.objects.all()
search_fields = ('name', 'owner') search_fields = ('name', 'owner')
filter_fields = list(search_fields) + ['id', 'slug'] filter_fields = list(search_fields) + ['id', 'slug']
@ -110,8 +119,8 @@ class OrganizationViewSet(viewsets.ModelViewSet):
'204': OpenApiResponse(description='The membership has been deleted'), '204': OpenApiResponse(description='The membership has been deleted'),
}) })
) )
class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin, class MembershipViewSet(mixins.RetrieveModelMixin, DestroyModelMixin,
mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet): mixins.ListModelMixin, PartialUpdateModelMixin, viewsets.GenericViewSet):
queryset = Membership.objects.all() queryset = Membership.objects.all()
ordering = '-id' ordering = '-id'
http_method_names = ['get', 'patch', 'delete', 'head', 'options'] http_method_names = ['get', 'patch', 'delete', 'head', 'options']
@ -165,7 +174,13 @@ class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin,
'204': OpenApiResponse(description='The invitation has been deleted'), '204': OpenApiResponse(description='The invitation has been deleted'),
}) })
) )
class InvitationViewSet(viewsets.ModelViewSet): class InvitationViewSet(viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
DestroyModelMixin,
):
queryset = Invitation.objects.all() queryset = Invitation.objects.all()
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
iam_organization_field = 'membership__organization' iam_organization_field = 'membership__organization'
@ -194,6 +209,7 @@ class InvitationViewSet(viewsets.ModelViewSet):
'organization': self.request.iam_context['organization'] 'organization': self.request.iam_context['organization']
} }
serializer.save(**extra_kwargs) serializer.save(**extra_kwargs)
signal_create.send(self, instance=serializer.instance)
def perform_update(self, serializer): def perform_update(self, serializer):
if 'accepted' in self.request.query_params: if 'accepted' in self.request.query_params:

@ -0,0 +1,12 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from django.apps import AppConfig
class WebhooksConfig(AppConfig):
name = "cvat.apps.webhooks"
def ready(self):
from . import signals # pylint: disable=unused-import

@ -0,0 +1,58 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from .models import WebhookTypeChoice
def event_name(action, resource):
return f"{action}:{resource}"
class Events:
RESOURCES = {
"project": ["create", "update", "delete"],
"task": ["create", "update", "delete"],
"issue": ["create", "update", "delete"],
"comment": ["create", "update", "delete"],
"invitation": ["create", "delete"], # TO-DO: implement invitation_updated,
"membership": ["update", "delete"],
"job": ["update"],
"organization": ["update"],
}
@classmethod
def select(cls, resources):
return [
f"{event_name(action, resource)}"
for resource in resources
for action in cls.RESOURCES.get(resource, [])
]
class EventTypeChoice:
@classmethod
def choices(cls):
return sorted((val, val.upper()) for val in AllEvents.events)
class AllEvents:
webhook_type = "all"
events = list(
event_name(action, resource)
for resource, actions in Events.RESOURCES.items()
for action in actions
)
class ProjectEvents:
webhook_type = WebhookTypeChoice.PROJECT
events = [event_name("update", "project")] \
+ Events.select(["job", "task", "issue", "comment"])
class OrganizationEvents:
webhook_type = WebhookTypeChoice.ORGANIZATION
events = [event_name("update", "organization")] \
+ Events.select(["membership", "invitation", "project"]) \
+ ProjectEvents.events

@ -0,0 +1,64 @@
# Generated by Django 3.2.15 on 2022-09-19 08:26
import cvat.apps.webhooks.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('engine', '0060_alter_label_parent'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('organizations', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Webhook',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('target_url', models.URLField()),
('description', models.CharField(blank=True, default='', max_length=128)),
('events', models.CharField(default='', max_length=4096)),
('type', models.CharField(choices=[('organization', 'ORGANIZATION'), ('project', 'PROJECT')], max_length=16)),
('content_type', models.CharField(choices=[('application/json', 'JSON')], default=cvat.apps.webhooks.models.WebhookContentTypeChoice['JSON'], max_length=64)),
('secret', models.CharField(blank=True, default='', max_length=64)),
('is_active', models.BooleanField(default=True)),
('enable_ssl', models.BooleanField(default=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='organizations.organization')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='engine.project')),
],
options={
'default_permissions': (),
},
),
migrations.CreateModel(
name='WebhookDelivery',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event', models.CharField(max_length=64)),
('status_code', models.CharField(max_length=128, null=True)),
('redelivery', models.BooleanField(default=False)),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('changed_fields', models.CharField(default='', max_length=4096)),
('request', models.JSONField(default=dict)),
('response', models.JSONField(default=dict)),
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='webhooks.webhook')),
],
options={
'default_permissions': (),
},
),
migrations.AddConstraint(
model_name='webhook',
constraint=models.CheckConstraint(check=models.Q(models.Q(('project_id__isnull', False), ('type', 'project')), models.Q(('organization_id__isnull', False), ('project_id__isnull', True), ('type', 'organization')), _connector='OR'), name='webhooks_project_or_organization'),
),
]

@ -0,0 +1,18 @@
# Generated by Django 3.2.15 on 2022-09-27 12:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('webhooks', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='webhookdelivery',
name='status_code',
field=models.IntegerField(choices=[('CONTINUE', 100), ('SWITCHING_PROTOCOLS', 101), ('PROCESSING', 102), ('OK', 200), ('CREATED', 201), ('ACCEPTED', 202), ('NON_AUTHORITATIVE_INFORMATION', 203), ('NO_CONTENT', 204), ('RESET_CONTENT', 205), ('PARTIAL_CONTENT', 206), ('MULTI_STATUS', 207), ('ALREADY_REPORTED', 208), ('IM_USED', 226), ('MULTIPLE_CHOICES', 300), ('MOVED_PERMANENTLY', 301), ('FOUND', 302), ('SEE_OTHER', 303), ('NOT_MODIFIED', 304), ('USE_PROXY', 305), ('TEMPORARY_REDIRECT', 307), ('PERMANENT_REDIRECT', 308), ('BAD_REQUEST', 400), ('UNAUTHORIZED', 401), ('PAYMENT_REQUIRED', 402), ('FORBIDDEN', 403), ('NOT_FOUND', 404), ('METHOD_NOT_ALLOWED', 405), ('NOT_ACCEPTABLE', 406), ('PROXY_AUTHENTICATION_REQUIRED', 407), ('REQUEST_TIMEOUT', 408), ('CONFLICT', 409), ('GONE', 410), ('LENGTH_REQUIRED', 411), ('PRECONDITION_FAILED', 412), ('REQUEST_ENTITY_TOO_LARGE', 413), ('REQUEST_URI_TOO_LONG', 414), ('UNSUPPORTED_MEDIA_TYPE', 415), ('REQUESTED_RANGE_NOT_SATISFIABLE', 416), ('EXPECTATION_FAILED', 417), ('MISDIRECTED_REQUEST', 421), ('UNPROCESSABLE_ENTITY', 422), ('LOCKED', 423), ('FAILED_DEPENDENCY', 424), ('UPGRADE_REQUIRED', 426), ('PRECONDITION_REQUIRED', 428), ('TOO_MANY_REQUESTS', 429), ('REQUEST_HEADER_FIELDS_TOO_LARGE', 431), ('UNAVAILABLE_FOR_LEGAL_REASONS', 451), ('INTERNAL_SERVER_ERROR', 500), ('NOT_IMPLEMENTED', 501), ('BAD_GATEWAY', 502), ('SERVICE_UNAVAILABLE', 503), ('GATEWAY_TIMEOUT', 504), ('HTTP_VERSION_NOT_SUPPORTED', 505), ('VARIANT_ALSO_NEGOTIATES', 506), ('INSUFFICIENT_STORAGE', 507), ('LOOP_DETECTED', 508), ('NOT_EXTENDED', 510), ('NETWORK_AUTHENTICATION_REQUIRED', 511)], default=None, null=True),
),
]

@ -0,0 +1,107 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from enum import Enum
from http import HTTPStatus
from django.contrib.auth.models import User
from django.db import models
from cvat.apps.engine.models import Project
from cvat.apps.organizations.models import Organization
class WebhookTypeChoice(str, Enum):
ORGANIZATION = "organization"
PROJECT = "project"
@classmethod
def choices(cls):
return tuple((x.value, x.name) for x in cls)
def __str__(self):
return self.value
class WebhookContentTypeChoice(str, Enum):
JSON = "application/json"
@classmethod
def choices(cls):
return tuple((x.value, x.name) for x in cls)
def __str__(self):
return self.value
class Webhook(models.Model):
target_url = models.URLField()
description = models.CharField(max_length=128, default="", blank=True)
events = models.CharField(max_length=4096, default="")
type = models.CharField(max_length=16, choices=WebhookTypeChoice.choices())
content_type = models.CharField(
max_length=64,
choices=WebhookContentTypeChoice.choices(),
default=WebhookContentTypeChoice.JSON,
)
secret = models.CharField(max_length=64, blank=True, default="")
is_active = models.BooleanField(default=True)
enable_ssl = models.BooleanField(default=True)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
# questionable: should we keep webhook if owner has been deleted?
owner = models.ForeignKey(
User, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
project = models.ForeignKey(
Project, null=True, on_delete=models.CASCADE, related_name="+"
)
organization = models.ForeignKey(
Organization, null=True, on_delete=models.CASCADE, related_name="+"
)
class Meta:
default_permissions = ()
constraints = [
models.CheckConstraint(
name="webhooks_project_or_organization",
check=(
models.Q(
type=WebhookTypeChoice.PROJECT.value, project_id__isnull=False
)
| models.Q(
type=WebhookTypeChoice.ORGANIZATION.value,
project_id__isnull=True,
organization_id__isnull=False,
)
),
)
]
class WebhookDelivery(models.Model):
webhook = models.ForeignKey(
Webhook, on_delete=models.CASCADE, related_name="deliveries"
)
event = models.CharField(max_length=64)
status_code = models.IntegerField(
choices=tuple((x.name, x.value) for x in HTTPStatus), null=True, default=None
)
redelivery = models.BooleanField(default=False)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
changed_fields = models.CharField(max_length=4096, default="")
request = models.JSONField(default=dict)
response = models.JSONField(default=dict)
class Meta:
default_permissions = ()

@ -0,0 +1,152 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from .event_type import EventTypeChoice, ProjectEvents, OrganizationEvents
from .models import (
Webhook,
WebhookContentTypeChoice,
WebhookTypeChoice,
WebhookDelivery,
)
from rest_framework import serializers
from cvat.apps.engine.serializers import BasicUserSerializer, WriteOnceMixin
class EventTypeValidator:
requires_context = True
def get_webhook_type(self, attrs, serializer):
if serializer.instance is not None:
return serializer.instance.type
return attrs.get("type")
def __call__(self, attrs, serializer):
if attrs.get("events") is not None:
webhook_type = self.get_webhook_type(attrs, serializer)
events = set(EventTypesSerializer().to_representation(attrs["events"]))
if (
webhook_type == WebhookTypeChoice.PROJECT
and not events.issubset(set(ProjectEvents.events))
) or (
webhook_type == WebhookTypeChoice.ORGANIZATION
and not events.issubset(set(OrganizationEvents.events))
):
raise serializers.ValidationError(
f"Invalid events list for {webhook_type} webhook"
)
class EventTypesSerializer(serializers.MultipleChoiceField):
def __init__(self, *args, **kwargs):
super().__init__(choices=EventTypeChoice.choices(), *args, **kwargs)
def to_representation(self, value):
if isinstance(value, list):
return sorted(super().to_representation(value))
return sorted(list(super().to_representation(value.split(","))))
def to_internal_value(self, data):
return ",".join(super().to_internal_value(data))
class EventsSerializer(serializers.Serializer):
webhook_type = serializers.ChoiceField(choices=WebhookTypeChoice.choices())
events = EventTypesSerializer()
class WebhookReadSerializer(serializers.ModelSerializer):
owner = BasicUserSerializer(read_only=True, required=False)
events = EventTypesSerializer(read_only=True)
type = serializers.ChoiceField(choices=WebhookTypeChoice.choices())
content_type = serializers.ChoiceField(choices=WebhookContentTypeChoice.choices())
last_status = serializers.IntegerField(
source="deliveries.last.status_code", read_only=True
)
last_delivery_date = serializers.DateTimeField(
source="deliveries.last.updated_date", read_only=True
)
class Meta:
model = Webhook
fields = (
"id",
"url",
"target_url",
"description",
"type",
"content_type",
"is_active",
"enable_ssl",
"created_date",
"updated_date",
"owner",
"project",
"organization",
"events",
"last_status",
"last_delivery_date",
)
read_only_fields = fields
class WebhookWriteSerializer(WriteOnceMixin, serializers.ModelSerializer):
events = EventTypesSerializer(write_only=True)
# Q: should be owner_id required or not?
owner_id = serializers.IntegerField(
write_only=True, allow_null=True, required=False
)
project_id = serializers.IntegerField(
write_only=True, allow_null=True, required=False
)
def to_representation(self, instance):
serializer = WebhookReadSerializer(instance, context=self.context)
return serializer.data
class Meta:
model = Webhook
fields = (
"target_url",
"description",
"type",
"content_type",
"secret",
"is_active",
"enable_ssl",
"owner_id",
"project_id",
"events",
)
write_once_fields = ("type", "owner_id", "project_id")
validators = [EventTypeValidator()]
def create(self, validated_data):
db_webhook = Webhook.objects.create(**validated_data)
return db_webhook
class WebhookDeliveryReadSerializer(serializers.ModelSerializer):
webhook_id = serializers.IntegerField(read_only=True)
class Meta:
model = WebhookDelivery
fields = (
"id",
"webhook_id",
"event",
"status_code",
"redelivery",
"created_date",
"updated_date",
"changed_fields",
"request",
"response",
)
read_only_fields = fields

@ -0,0 +1,221 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import hashlib
import hmac
from http import HTTPStatus
import json
import django_rq
import requests
from django.dispatch import Signal, receiver
from cvat.apps.engine.models import Project
from cvat.apps.engine.serializers import BasicUserSerializer
from cvat.apps.organizations.models import Organization
from .event_type import EventTypeChoice, event_name
from .models import Webhook, WebhookDelivery, WebhookTypeChoice
WEBHOOK_TIMEOUT = 10
RESPONSE_SIZE_LIMIT = 1 * 1024 * 1024 # 1 MB
signal_update = Signal()
signal_create = Signal()
signal_delete = Signal()
signal_redelivery = Signal()
signal_ping = Signal()
def send_webhook(webhook, payload, delivery):
headers = {}
if webhook.secret:
headers["X-Signature-256"] = (
"sha256="
+ hmac.new(
webhook.secret.encode("utf-8"),
(json.dumps(payload) + "\n").encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
)
response_body = None
try:
response = requests.post(
webhook.target_url,
json=payload,
verify=webhook.enable_ssl,
headers=headers,
timeout=WEBHOOK_TIMEOUT,
stream=True,
)
status_code = response.status_code
response_body = response.raw.read(RESPONSE_SIZE_LIMIT + 1, decode_content=True)
except requests.ConnectionError:
status_code = HTTPStatus.BAD_GATEWAY
except requests.Timeout:
status_code = HTTPStatus.GATEWAY_TIMEOUT
setattr(delivery, "status_code", status_code)
if response_body is not None and len(response_body) < RESPONSE_SIZE_LIMIT + 1:
setattr(delivery, "response", response_body.decode("utf-8"))
delivery.save()
def add_to_queue(webhook, payload, redelivery=False):
delivery = WebhookDelivery.objects.create(
webhook_id=webhook.id,
event=payload["event"],
status_code=None,
changed_fields=",".join(list(payload.get("before_update", {}).keys())),
redelivery=redelivery,
request=payload,
response="",
)
queue = django_rq.get_queue("webhooks")
queue.enqueue_call(func=send_webhook, args=(webhook, payload, delivery))
return delivery
def select_webhooks(project_id, org_id, event):
selected_webhooks = []
if org_id is not None:
webhooks = Webhook.objects.filter(
is_active=True,
events__contains=event,
type=WebhookTypeChoice.ORGANIZATION,
organization=org_id,
)
selected_webhooks += list(webhooks)
if project_id is not None:
webhooks = Webhook.objects.filter(
is_active=True,
events__contains=event,
type=WebhookTypeChoice.PROJECT,
organization=org_id,
project=project_id,
)
selected_webhooks += list(webhooks)
return selected_webhooks
def payload(data, request):
return {
**data,
"sender": BasicUserSerializer(request.user, context={"request": request}).data,
}
def project_id(instance):
if isinstance(instance, Project):
return instance.id
try:
pid = getattr(instance, "project_id", None)
if pid is None:
return instance.get_project_id()
return pid
except Exception:
return None
def organization_id(instance):
if isinstance(instance, Organization):
return instance.id
try:
oid = getattr(instance, "organization_id", None)
if oid is None:
return instance.get_organization_id()
return oid
except Exception:
return None
@receiver(signal_update)
def update(sender, instance=None, old_values=None, **kwargs):
event = event_name("update", sender.basename)
if event not in map(lambda a: a[0], EventTypeChoice.choices()):
return
serializer = sender.get_serializer_class()(
instance=instance, context={"request": sender.request}
)
pid = project_id(instance)
oid = organization_id(instance)
if not any((oid, pid)):
return
data = {
"event": event,
sender.basename: serializer.data,
"before_update": old_values,
}
for webhook in select_webhooks(pid, oid, event):
data.update({"webhook_id": webhook.id})
add_to_queue(webhook, payload(data, sender.request))
@receiver(signal_create)
def resource_created(sender, instance=None, **kwargs):
event = event_name("create", sender.basename)
if event not in map(lambda a: a[0], EventTypeChoice.choices()):
return
pid = project_id(instance)
oid = organization_id(instance)
if not any((oid, pid)):
return
serializer = sender.get_serializer_class()(
instance=instance, context={"request": sender.request}
)
data = {"event": event, sender.basename: serializer.data}
for webhook in select_webhooks(pid, oid, event):
data.update({"webhook_id": webhook.id})
add_to_queue(webhook, payload(data, sender.request))
@receiver(signal_delete)
def resource_deleted(sender, instance=None, **kwargs):
event = event_name("delete", sender.basename)
if event not in map(lambda a: a[0], EventTypeChoice.choices()):
return
pid = project_id(instance)
oid = organization_id(instance)
if not any((oid, pid)):
return
serializer = sender.get_serializer_class()(
instance=instance, context={"request": sender.request}
)
data = {"event": event, sender.basename: serializer.data}
for webhook in select_webhooks(pid, oid, event):
data.update({"webhook_id": webhook.id})
add_to_queue(webhook, payload(data, sender.request))
@receiver(signal_redelivery)
def redelivery(sender, data=None, **kwargs):
add_to_queue(sender.get_object(), data, redelivery=True)
@receiver(signal_ping)
def ping(sender, serializer, **kwargs):
data = {"event": "ping", "webhook": serializer.data}
delivery = add_to_queue(serializer.instance, payload(data, sender.request))
return delivery

@ -0,0 +1,11 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from rest_framework.routers import DefaultRouter
from .views import WebhookViewSet
router = DefaultRouter(trailing_slash=False)
router.register("webhooks", WebhookViewSet)
urlpatterns = router.urls

@ -0,0 +1,195 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
OpenApiTypes,
extend_schema,
extend_schema_view,
)
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
from cvat.apps.iam.permissions import WebhookPermission
from .event_type import AllEvents, OrganizationEvents, ProjectEvents
from .models import Webhook, WebhookDelivery, WebhookTypeChoice
from .serializers import (
EventsSerializer,
WebhookDeliveryReadSerializer,
WebhookReadSerializer,
WebhookWriteSerializer,
)
from .signals import signal_ping, signal_redelivery
@extend_schema(tags=["webhooks"])
@extend_schema_view(
retrieve=extend_schema(
summary="Method returns details of a webhook",
responses={"200": WebhookReadSerializer},
),
list=extend_schema(
summary="Method returns a paginated list of webhook according to query parameters",
responses={"200": WebhookReadSerializer(many=True)},
),
update=extend_schema(
summary="Method updates a webhook by id",
responses={"200": WebhookWriteSerializer},
),
partial_update=extend_schema(
summary="Methods does a partial update of chosen fields in a webhook",
responses={"200": WebhookWriteSerializer},
),
create=extend_schema(
summary="Method creates a webhook", responses={"201": WebhookWriteSerializer}
),
destroy=extend_schema(
summary="Method deletes a webhook",
responses={"204": OpenApiResponse(description="The webhook has been deleted")},
),
)
class WebhookViewSet(viewsets.ModelViewSet):
queryset = Webhook.objects.all()
ordering = "-id"
http_method_names = ["get", "post", "delete", "patch", "put"]
search_fields = ("target_url", "owner", "type", "description")
filter_fields = list(search_fields) + ["id", "project_id", "updated_date"]
ordering_fields = filter_fields
lookup_fields = {"owner": "owner__username"}
iam_organization_field = "organization"
def get_serializer_class(self):
if self.request.path.endswith("redelivery") or self.request.path.endswith(
"ping"
):
return None
else:
if self.request.method in SAFE_METHODS:
return WebhookReadSerializer
else:
return WebhookWriteSerializer
def get_queryset(self):
queryset = super().get_queryset()
if self.action == "list":
perm = WebhookPermission.create_scope_list(self.request)
queryset = perm.filter(queryset)
return queryset
def perform_create(self, serializer):
serializer.save(
owner=self.request.user,
organization=self.request.iam_context["organization"],
)
@extend_schema(
summary="Method return a list of available webhook events",
parameters=[
OpenApiParameter(
"type",
description="Type of webhook",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
required=False,
)
],
responses={"200": OpenApiResponse(EventsSerializer)},
)
@action(detail=False, methods=["GET"], serializer_class=EventsSerializer)
def events(self, request):
webhook_type = request.query_params.get("type", "all")
events = None
if webhook_type == "all":
events = AllEvents
elif webhook_type == WebhookTypeChoice.PROJECT:
events = ProjectEvents
elif webhook_type == WebhookTypeChoice.ORGANIZATION:
events = OrganizationEvents
if events is None:
return Response(
"Incorrect value of type parameter", status=status.HTTP_400_BAD_REQUEST
)
return Response(EventsSerializer().to_representation(events))
@extend_schema(
summary="Method return a list of deliveries for a specific webhook",
responses={"200": WebhookDeliveryReadSerializer(many=True)},
)
@action(
detail=True, methods=["GET"], serializer_class=WebhookDeliveryReadSerializer
)
def deliveries(self, request, pk):
self.get_object()
queryset = WebhookDelivery.objects.filter(webhook_id=pk).order_by(
"-updated_date"
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = WebhookDeliveryReadSerializer(
page, many=True, context={"request": request}
)
return self.get_paginated_response(serializer.data)
serializer = WebhookDeliveryReadSerializer(
queryset, many=True, context={"request": request}
)
return Response(serializer.data)
@extend_schema(
summary="Method return a specific delivery for a specific webhook",
responses={"200": WebhookDeliveryReadSerializer},
)
@action(
detail=True,
methods=["GET"],
url_path=r"deliveries/(?P<delivery_id>\d+)",
serializer_class=WebhookDeliveryReadSerializer,
)
def retrieve_delivery(self, request, pk, delivery_id):
self.get_object()
queryset = WebhookDelivery.objects.get(webhook_id=pk, id=delivery_id)
serializer = WebhookDeliveryReadSerializer(
queryset, context={"request": request}
)
return Response(serializer.data)
@extend_schema(summary="Method redeliver a specific webhook delivery")
@action(
detail=True,
methods=["POST"],
url_path=r"deliveries/(?P<delivery_id>\d+)/redelivery",
)
def redelivery(self, request, pk, delivery_id):
delivery = WebhookDelivery.objects.get(webhook_id=pk, id=delivery_id)
signal_redelivery.send(sender=self, data=delivery.request)
# Questionable: should we provide a body for this response?
return Response({})
@extend_schema(
summary="Method send ping webhook",
responses={"200": WebhookDeliveryReadSerializer},
)
@action(
detail=True, methods=["POST"], serializer_class=WebhookDeliveryReadSerializer
)
def ping(self, request, pk):
instance = self.get_object()
serializer = WebhookReadSerializer(instance, context={"request": request})
delivery = signal_ping.send(sender=self, serializer=serializer)[0][1]
serializer = WebhookDeliveryReadSerializer(
delivery, context={"request": request}
)
return Response(serializer.data)

@ -128,6 +128,7 @@ INSTALLED_APPS = [
'cvat.apps.restrictions', 'cvat.apps.restrictions',
'cvat.apps.lambda_manager', 'cvat.apps.lambda_manager',
'cvat.apps.opencv', 'cvat.apps.opencv',
'cvat.apps.webhooks',
] ]
SITE_ID = 1 SITE_ID = 1
@ -277,6 +278,12 @@ RQ_QUEUES = {
'PORT': 6379, 'PORT': 6379,
'DB': 0, 'DB': 0,
'DEFAULT_TIMEOUT': '24h' 'DEFAULT_TIMEOUT': '24h'
},
'webhooks': {
'HOST': 'localhost',
'PORT': 6379,
'DB': 0,
'DEFAULT_TIMEOUT': '1h'
} }
} }

@ -40,5 +40,8 @@ if apps.is_installed('cvat.apps.lambda_manager'):
if apps.is_installed('cvat.apps.opencv'): if apps.is_installed('cvat.apps.opencv'):
urlpatterns.append(path('opencv/', include('cvat.apps.opencv.urls'))) urlpatterns.append(path('opencv/', include('cvat.apps.opencv.urls')))
if apps.is_installed('cvat.apps.webhooks'):
urlpatterns.append(path('api/', include('cvat.apps.webhooks.urls')))
if apps.is_installed('silk'): if apps.is_installed('silk'):
urlpatterns.append(path('profiler/', include('silk.urls'))) urlpatterns.append(path('profiler/', include('silk.urls')))

@ -117,6 +117,27 @@ services:
networks: networks:
- cvat - cvat
cvat_worker_webhooks:
container_name: cvat_worker_webhooks
image: cvat/server:${CVAT_VERSION:-dev}
restart: always
depends_on:
- cvat_redis
- cvat_db
- cvat_opa
environment:
CVAT_REDIS_HOST: 'cvat_redis'
CVAT_POSTGRES_HOST: 'cvat_db'
no_proxy: elasticsearch,kibana,logstash,nuclio,opa,${no_proxy}
NUMPROCS: 1
command: -c supervisord/worker.webhooks.conf
volumes:
- cvat_data:/home/django/data
- cvat_keys:/home/django/keys
- cvat_logs:/home/django/logs
networks:
- cvat
cvat_ui: cvat_ui:
container_name: cvat_ui container_name: cvat_ui
image: cvat/ui:${CVAT_VERSION:-dev} image: cvat/ui:${CVAT_VERSION:-dev}

@ -42,7 +42,6 @@
"eslint-config-airbnb-base": "14.2.1", "eslint-config-airbnb-base": "14.2.1",
"eslint-config-airbnb-typescript": "^12.0.0", "eslint-config-airbnb-typescript": "^12.0.0",
"eslint-plugin-cypress": "^2.11.2", "eslint-plugin-cypress": "^2.11.2",
"eslint-plugin-header": "^3.1.0",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^26.5.3", "eslint-plugin-jest": "^26.5.3",
"eslint-plugin-jsx-a11y": "^6.3.1", "eslint-plugin-jsx-a11y": "^6.3.1",

@ -35,6 +35,12 @@ command=%(ENV_HOME)s/wait-for-it.sh %(ENV_CVAT_REDIS_HOST)s:6379 -t 0 -- bash -i
environment=SSH_AUTH_SOCK="/tmp/ssh-agent.sock" environment=SSH_AUTH_SOCK="/tmp/ssh-agent.sock"
numprocs=1 numprocs=1
[program:rqworker_webhooks]
command=%(ENV_HOME)s/wait-for-it.sh %(ENV_CVAT_REDIS_HOST)s:6379 -t 0 -- bash -ic \
"exec python3 %(ENV_HOME)s/manage.py rqworker -v 3 webhooks"
environment=SSH_AUTH_SOCK="/tmp/ssh-agent.sock"
numprocs=1
[program:git_status_updater] [program:git_status_updater]
command=%(ENV_HOME)s/wait-for-it.sh %(ENV_CVAT_REDIS_HOST)s:6379 -t 0 -- bash -ic \ command=%(ENV_HOME)s/wait-for-it.sh %(ENV_CVAT_REDIS_HOST)s:6379 -t 0 -- bash -ic \
"python3 ~/manage.py update_git_states" "python3 ~/manage.py update_git_states"

@ -0,0 +1,36 @@
[unix_http_server]
file = /tmp/supervisord/supervisor.sock
[supervisorctl]
serverurl = unix:///tmp/supervisord/supervisor.sock
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisord]
nodaemon=true
logfile=%(ENV_HOME)s/logs/supervisord.log ; supervisord log file
logfile_maxbytes=50MB ; maximum size of logfile before rotation
logfile_backups=10 ; number of backed up logfiles
loglevel=debug ; info, debug, warn, trace
pidfile=/tmp/supervisord/supervisord.pid ; pidfile location
childlogdir=%(ENV_HOME)s/logs/ ; where child log files will live
[program:ssh-agent]
command=bash -c "rm /tmp/ssh-agent.sock -f && /usr/bin/ssh-agent -d -a /tmp/ssh-agent.sock"
priority=1
autorestart=true
[program:rqworker_webhooks]
command=%(ENV_HOME)s/wait-for-it.sh %(ENV_CVAT_REDIS_HOST)s:6379 -t 0 -- bash -ic \
"exec python3 %(ENV_HOME)s/manage.py rqworker -v 3 webhooks"
environment=SSH_AUTH_SOCK="/tmp/ssh-agent.sock"
numprocs=%(ENV_NUMPROCS)s
[program:clamav_update]
command=bash -c "if [ \"${CLAM_AV}\" = 'yes' ]; then /usr/bin/freshclam -d \
-l %(ENV_HOME)s/logs/freshclam.log --foreground=true; fi"
numprocs=1
environment=SSH_AUTH_SOCK="/tmp/ssh-agent.sock"

@ -14,7 +14,7 @@ module.exports = {
'.eslintrc.js', '.eslintrc.js',
'lint-staged.config.js', 'lint-staged.config.js',
], ],
plugins: ['security', 'no-unsanitized', 'eslint-plugin-header', 'import'], plugins: ['security', 'no-unsanitized', 'import'],
extends: [ extends: [
'eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM', 'plugin:cypress/recommended', 'eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM', 'plugin:cypress/recommended',
'airbnb-base', 'plugin:import/errors', 'plugin:import/warnings', 'airbnb-base', 'plugin:import/errors', 'plugin:import/warnings',

@ -13,6 +13,7 @@
"testFiles": [ "testFiles": [
"auth_page.js", "auth_page.js",
"skeletons_pipeline.js", "skeletons_pipeline.js",
"webhooks.js",
"actions_tasks/**/*.js", "actions_tasks/**/*.js",
"actions_tasks2/**/*.js", "actions_tasks2/**/*.js",
"actions_tasks3/**/*.js", "actions_tasks3/**/*.js",

@ -0,0 +1,105 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
context('Webhooks pipeline.', () => {
const organizationParams = {
shortName: 'WebhooksOrg',
fullName: 'Organization full name. Only for test.',
description: 'This organization was created to test the functionality.',
email: 'testorganization@local.local',
phoneNumber: '+70000000000',
location: 'Country, State, Address, 000000',
};
const orgWebhookParams = {
targetURL: 'https://localhost:3001/organization',
description: 'Sample description',
secret: 'Super secret',
enableSSL: true,
isActive: true,
events: [
'project', 'job', 'task',
],
};
const projectWebhookParams = {
targetURL: 'https://localhost:3001/project',
description: 'Sample description',
secret: 'Super secret',
enableSSL: true,
isActive: true,
};
const newOrganizationWebhookParams = {
targetURL: 'https://localhost:3001/edited',
description: 'Edited description',
secret: 'Super secret',
enableSSL: true,
isActive: false,
events: [
'job',
],
};
const project = {
name: 'Project for webhooks',
label: 'car',
attrName: 'color',
attrVaue: 'red',
multiAttrParams: false,
};
before(() => {
cy.visit('auth/login');
cy.login();
cy.createOrganization(organizationParams);
cy.activateOrganization(organizationParams.shortName);
cy.visit('/projects');
cy.createProjects(
project.name,
project.label,
project.attrName,
project.attrVaue,
project.multiAttrParams,
);
});
after(() => {
cy.logout();
cy.getAuthKey().then((authKey) => {
cy.deleteProjects(authKey, [project.name]);
cy.deleteOrganizations(authKey, [organizationParams.shortName]);
});
});
describe('Test organization webhook', () => {
it('Open the organization. Create/update/delete webhook.', () => {
cy.openOrganization(organizationParams.shortName);
cy.openOrganizationWebhooks();
cy.createWebhook(orgWebhookParams);
cy.get('.cvat-webhooks-list').within(() => {
cy.contains(orgWebhookParams.description).should('exist');
cy.contains(orgWebhookParams.targetURL).should('exist');
});
cy.editWebhook(orgWebhookParams.description, newOrganizationWebhookParams);
cy.get('.cvat-webhooks-list').within(() => {
cy.contains(newOrganizationWebhookParams.description).should('exist');
cy.contains(newOrganizationWebhookParams.targetURL).should('exist');
});
cy.deleteWebhook(newOrganizationWebhookParams.description);
});
});
describe('Test project webhook', () => {
it('Open the project. Create webhook.', () => {
cy.goToProjectsList();
cy.openProject(project.name);
cy.openProjectWebhooks();
cy.createWebhook(projectWebhookParams);
cy.get('.cvat-webhooks-list').within(() => {
cy.contains(projectWebhookParams.description).should('exist');
cy.contains(projectWebhookParams.targetURL).should('exist');
});
});
});
});

@ -0,0 +1,81 @@
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
Cypress.Commands.add('createWebhook', (webhookData) => {
cy.get('.cvat-create-webhook').click();
cy.get('.cvat-setup-webhook-content').should('exist');
cy.setUpWebhook(webhookData);
cy.get('.cvat-notification-create-webhook-success').should('exist').find('[data-icon="close"]').click();
cy.get('.cvat-webhooks-go-back').click();
});
Cypress.Commands.add('openWebhookActions', (description) => {
cy.contains(description).parents('.cvat-webhooks-list-item').within(() => {
cy.get('.cvat-webhooks-page-actions-button').trigger('mouseover');
});
});
Cypress.Commands.add('editWebhook', (description, webhookData) => {
cy.openWebhookActions(description);
cy.contains('[role="menuitem"]', 'Edit').click();
cy.get('.cvat-setup-webhook-content').should('exist');
cy.setUpWebhook(webhookData);
cy.get('.cvat-notification-update-webhook-success').should('exist').find('[data-icon="close"]').click();
cy.get('.cvat-webhooks-go-back').click();
});
Cypress.Commands.add('deleteWebhook', (description) => {
cy.openWebhookActions(description);
cy.contains('[role="menuitem"]', 'Delete').click();
cy.get('.cvat-modal-confirm-remove-webhook')
.should('contain', 'Are you sure you want to remove the hook?')
.within(() => {
cy.contains('button', 'OK').click();
});
cy.contains(description).parents('.cvat-webhooks-list-item').should('have.css', 'opacity', '0.5');
});
Cypress.Commands.add('setUpWebhook', (webhookData) => {
cy.get('#targetURL').clear().type(webhookData.targetURL);
cy.get('#description').clear().type(webhookData.description);
cy.get('#secret').clear().type(webhookData.secret);
if (!webhookData.enableSSL) cy.get('#enableSSL').uncheck();
if (!webhookData.isActive) cy.get('#isActive').uncheck();
if (webhookData.events && Array.isArray(webhookData.events)) {
cy.get('#eventsMethod')
.within(() => {
cy.contains('Select individual events').click();
});
cy.get('.cvat-setup-webhook-content').within(() => {
cy.get('.cvat-webhook-detailed-events').within(() => {
cy.get('[type="checkbox"]').uncheck();
for (const event of webhookData.events) {
cy.contains(event).click();
}
});
});
}
cy.get('.cvat-setup-webhook-content').within(() => {
cy.contains('Submit').click();
});
});
Cypress.Commands.add('openOrganizationWebhooks', () => {
cy.get('.cvat-organization-page-actions-button').trigger('mouseover');
cy.get('.cvat-organization-actions-menu').within(() => {
cy.contains('[role="menuitem"]', 'Setup webhooks').click();
});
cy.get('.cvat-spinner').should('not.exist');
cy.get('.cvat-webhooks-page').should('exist');
});
Cypress.Commands.add('openProjectWebhooks', () => {
cy.get('.cvat-project-page-actions-button').trigger('mouseover');
cy.get('.cvat-project-actions-menu').within(() => {
cy.contains('[role="menuitem"]', 'Setup webhooks').click();
});
cy.get('.cvat-spinner').should('not.exist');
cy.get('.cvat-webhooks-page').should('exist');
});

@ -12,6 +12,7 @@ require('./commands_models');
require('./commands_opencv'); require('./commands_opencv');
require('./commands_organizations'); require('./commands_organizations');
require('./commands_cloud_storages'); require('./commands_cloud_storages');
require('./commands_webhooks');
require('@cypress/code-coverage/support'); require('@cypress/code-coverage/support');
require('cypress-real-events/support'); require('cypress-real-events/support');

@ -0,0 +1,17 @@
version: '3.3'
services:
webhook_receiver:
image: python:3.9-slim
restart: always
command: python3 /tmp/server.py
env_file:
- ./tests/python/webhook_receiver/.env
expose:
- ${SERVER_PORT}
volumes:
- ./tests/python/webhook_receiver:/tmp
networks:
cvat:
aliases:
- webhooks

@ -20,13 +20,8 @@ from .utils import export_dataset
@pytest.mark.usefixtures('dontchangedb') @pytest.mark.usefixtures('dontchangedb')
class TestGetProjects: class TestGetProjects:
def _find_project_by_user_org(self, user, projects, is_project_staff_flag, is_project_staff): def _find_project_by_user_org(self, user, projects, is_project_staff_flag, is_project_staff):
if is_project_staff_flag:
for p in projects: for p in projects:
if is_project_staff(user['id'], p['id']): if is_project_staff(user['id'], p['id']) == is_project_staff_flag:
return p['id']
else:
for p in projects:
if not is_project_staff(user['id'], p['id']):
return p['id'] return p['id']
def _test_response_200(self, username, project_id, **kwargs): def _test_response_200(self, username, project_id, **kwargs):
@ -78,48 +73,43 @@ class TestGetProjects:
) )
self._test_response_403(user['username'], project['id']) self._test_response_403(user['username'], project['id'])
# Member of organization that has role supervisor or worker cannot see
# project if this member not in [project:owner, project:assignee]
@pytest.mark.parametrize('role', ('supervisor', 'worker')) @pytest.mark.parametrize('role', ('supervisor', 'worker'))
def test_if_supervisor_or_worker_cannot_see_project(self, projects, is_project_staff, def test_if_supervisor_or_worker_cannot_see_project(self, projects, is_project_staff,
find_users, role): find_users, role):
non_admins = find_users(role=role, exclude_privilege='admin') user, pid = next((
assert non_admins is not None (user, project['id'])
for user in find_users(role=role, exclude_privilege='admin')
for project in projects
if project['organization'] == user['org'] \
and not is_project_staff(user['id'], project['id'])
))
project_id = self._find_project_by_user_org(non_admins[0], projects, False, is_project_staff) self._test_response_403(user['username'], pid)
assert project_id is not None
self._test_response_403(non_admins[0]['username'], project_id)
# Member of organization that has role maintainer or owner can see any
# project even this member not in [project:owner, project:assignee]
@pytest.mark.parametrize('role', ('maintainer', 'owner')) @pytest.mark.parametrize('role', ('maintainer', 'owner'))
def test_if_maintainer_or_owner_can_see_project(self, find_users, projects, is_project_staff, role): def test_if_maintainer_or_owner_can_see_project(self, find_users, projects, is_project_staff, role):
non_admins = find_users(role=role, exclude_privilege='admin') user, pid = next((
assert non_admins is not None (user, project['id'])
for user in find_users(role=role, exclude_privilege='admin')
project_id = self._find_project_by_user_org(non_admins[0], projects, False, is_project_staff) for project in projects
assert project_id is not None if project['organization'] == user['org'] \
and not is_project_staff(user['id'], project['id'])
))
self._test_response_200(non_admins[0]['username'], project_id, org_id=non_admins[0]['org']) self._test_response_200(user['username'], pid, org_id=user['org'])
# Member of organization that has role supervisor or worker can see
# project if this member in [project:owner, project:assignee]
@pytest.mark.parametrize('role', ('supervisor', 'worker')) @pytest.mark.parametrize('role', ('supervisor', 'worker'))
def test_if_org_member_supervisor_or_worker_can_see_project(self, projects, def test_if_org_member_supervisor_or_worker_can_see_project(self, projects,
find_users, is_project_staff, role): find_users, is_project_staff, role):
non_admins = find_users(role=role, exclude_privilege='admin') user, pid = next((
assert len(non_admins) (user, project['id'])
for user in find_users(role=role, exclude_privilege='admin')
for project in projects
if project['organization'] == user['org'] \
and is_project_staff(user['id'], project['id'])
))
for u in non_admins: self._test_response_200(user['username'], pid, org_id=user['org'])
project_id = self._find_project_by_user_org(u, projects, True, is_project_staff)
if project_id:
user_in_project = u
break
assert project_id is not None
self._test_response_200(user_in_project['username'], project_id, org_id=user_in_project['org'])
class TestGetProjectBackup: class TestGetProjectBackup:
def _test_can_get_project_backup(self, username, pid, **kwargs): def _test_can_get_project_backup(self, username, pid, **kwargs):
@ -159,7 +149,9 @@ class TestGetProjectBackup:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if not is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if not is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
self._test_cannot_get_project_backup(user['username'], project['id'], org_id=project['organization']) self._test_cannot_get_project_backup(user['username'], project['id'], org_id=project['organization'])
@ -171,7 +163,9 @@ class TestGetProjectBackup:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
self._test_can_get_project_backup(user['username'], project['id'], org_id=project['organization']) self._test_can_get_project_backup(user['username'], project['id'], org_id=project['organization'])
@ -183,7 +177,9 @@ class TestGetProjectBackup:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
self._test_can_get_project_backup(user['username'], project['id'], org_id=project['organization']) self._test_can_get_project_backup(user['username'], project['id'], org_id=project['organization'])
@ -195,7 +191,9 @@ class TestGetProjectBackup:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if not is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if not is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
self._test_cannot_get_project_backup(user['username'], project['id'], org_id=project['organization']) self._test_cannot_get_project_backup(user['username'], project['id'], org_id=project['organization'])
@ -207,7 +205,9 @@ class TestGetProjectBackup:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if not is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if not is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
self._test_can_get_project_backup(user['username'], project['id'], org_id=project['organization']) self._test_can_get_project_backup(user['username'], project['id'], org_id=project['organization'])
@ -219,7 +219,9 @@ class TestGetProjectBackup:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if not is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if not is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
self._test_can_get_project_backup(user['username'], project['id'], org_id=project['organization']) self._test_can_get_project_backup(user['username'], project['id'], org_id=project['organization'])
@ -248,7 +250,7 @@ class TestPostProjects:
self._test_create_project_403(username, spec) self._test_create_project_403(username, spec)
@pytest.mark.parametrize('privilege', ('admin', 'business', 'user')) @pytest.mark.parametrize('privilege', ('admin', 'business', 'user'))
def test_is_user_can_create_project(self, find_users, privilege): def test_if_user_can_create_project(self, find_users, privilege):
privileged_users = find_users(privilege=privilege) privileged_users = find_users(privilege=privilege)
assert len(privileged_users) assert len(privileged_users)
@ -427,7 +429,7 @@ class TestPatchProjectLabel:
assert len(response.json()['labels']) == len(project['labels']) - 1 assert len(response.json()['labels']) == len(project['labels']) - 1
def test_admin_can_delete_skeleton_label(self, projects): def test_admin_can_delete_skeleton_label(self, projects):
project = deepcopy(list(projects)[0]) project = deepcopy(projects[5])
labels = project['labels'][0] labels = project['labels'][0]
labels.update({'deleted': True}) labels.update({'deleted': True})
response = patch_method('admin1', f'/projects/{project["id"]}', {'labels': [labels]}) response = patch_method('admin1', f'/projects/{project["id"]}', {'labels': [labels]})
@ -456,7 +458,9 @@ class TestPatchProjectLabel:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if not is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if not is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
labels = {'name': 'new name'} labels = {'name': 'new name'}
@ -471,7 +475,9 @@ class TestPatchProjectLabel:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if not is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if not is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
labels = {'name': 'new name'} labels = {'name': 'new name'}
@ -485,7 +491,9 @@ class TestPatchProjectLabel:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if not is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if not is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
labels = {'name': 'new name'} labels = {'name': 'new name'}
@ -499,7 +507,9 @@ class TestPatchProjectLabel:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
labels = {'name': 'new name'} labels = {'name': 'new name'}
@ -514,7 +524,9 @@ class TestPatchProjectLabel:
user, project = next( user, project = next(
(user, project) (user, project)
for user, project in product(users, projects) for user, project in product(users, projects)
if not is_project_staff(user['id'], project['id']) and is_org_member(user['id'], project['organization']) if not is_project_staff(user['id'], project['id'])
and project['organization']
and is_org_member(user['id'], project['organization'])
) )
labels = {'name': 'new name'} labels = {'name': 'new name'}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,98 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import json
import os.path as osp
from http import HTTPStatus
import pytest
from deepdiff import DeepDiff
from shared.fixtures.init import CVAT_ROOT_DIR, _run
from shared.utils.config import get_method, patch_method, post_method
# Testing webhook functionality:
# - webhook_receiver container receive post request and return responses with the same body
# - cvat save response body for each delivery
#
# So idea of this testing system is quite simple:
# 1) trigger some webhook
# 2) check that webhook is sent by checking value of `response` field for the last delivery of this webhook
def target_url():
env_data = {}
with open(osp.join(CVAT_ROOT_DIR, "tests", "python", "webhook_receiver", ".env"), "r") as f:
for line in f:
name, value = tuple(line.strip().split("="))
env_data[name] = value
container_id = _run(
"docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' test_webhook_receiver_1"
)[0].strip()[1:-1]
return f'http://{container_id}:{env_data["SERVER_PORT"]}/{env_data["PAYLOAD_ENDPOINT"]}'
def webhook_spec(events, project_id=None, webhook_type="organization"):
# Django URL field doesn't allow to use http://webhooks:2020/payload (using alias)
# So we forced to use ip address of webhook receiver container
return {
"target_url": target_url(),
"content_type": "application/json",
"enable_ssl": False,
"events": events,
"is_active": True,
"project_id": project_id,
"type": webhook_type,
}
@pytest.mark.usefixtures("changedb")
class TestWebhookProjectEvents:
def test_webhook_project_update(self):
events = ["update:project"]
patch_data = {"name": "new_project_name"}
# create project
response = post_method("admin1", "projects", {"name": "project"})
assert response.status_code == HTTPStatus.CREATED
project = response.json()
# create webhook
response = post_method(
"admin1", "webhooks", webhook_spec(events, project["id"], webhook_type="project")
)
assert response.status_code == HTTPStatus.CREATED
webhook = response.json()
# update project
response = patch_method("admin1", f"projects/{project['id']}", patch_data)
assert response.status_code == HTTPStatus.OK
# get list of deliveries of webhook
response = get_method("admin1", f"webhooks/{webhook['id']}/deliveries")
assert response.status_code == HTTPStatus.OK
response_data = response.json()
# check that we sent only one webhook
assert response_data["count"] == 1
# check value of payload that CVAT sent
payload = json.loads(response_data["results"][0]["response"])
assert payload["event"] == events[0]
assert payload["sender"]["username"] == "admin1"
assert payload["before_update"]["name"] == project["name"]
project.update(patch_data)
assert (
DeepDiff(
payload["project"],
project,
ignore_order=True,
exclude_paths=["root['updated_date']"],
)
== {}
)

@ -36,7 +36,7 @@
"pk": 1, "pk": 1,
"fields": { "fields": {
"password": "pbkdf2_sha256$260000$DevmxlmLwciP1P6sZs2Qag$U9DFtjTWx96Sk95qY6UXVcvpdQEP2LcoFBftk5D2RKY=", "password": "pbkdf2_sha256$260000$DevmxlmLwciP1P6sZs2Qag$U9DFtjTWx96Sk95qY6UXVcvpdQEP2LcoFBftk5D2RKY=",
"last_login": "2022-09-22T14:21:28.429Z", "last_login": "2022-09-28T12:20:48.633Z",
"is_superuser": true, "is_superuser": true,
"username": "admin1", "username": "admin1",
"first_name": "Admin", "first_name": "Admin",
@ -58,7 +58,7 @@
"pk": 2, "pk": 2,
"fields": { "fields": {
"password": "pbkdf2_sha256$260000$Pf2xYWXBedoAJ504jyDD8e$8sJ244Ai0xhZrUTelapPNHlEg7CV0cCUaxbcxZtfaug=", "password": "pbkdf2_sha256$260000$Pf2xYWXBedoAJ504jyDD8e$8sJ244Ai0xhZrUTelapPNHlEg7CV0cCUaxbcxZtfaug=",
"last_login": "2022-03-17T07:22:09.327Z", "last_login": "2022-09-28T12:15:35.182Z",
"is_superuser": false, "is_superuser": false,
"username": "user1", "username": "user1",
"first_name": "User", "first_name": "User",
@ -80,7 +80,7 @@
"pk": 3, "pk": 3,
"fields": { "fields": {
"password": "pbkdf2_sha256$260000$9YZSJ0xF4Kvjsm2Fwflciy$zRpcqAMLaJBbqTRS09NkZovOHtcdy6haZxu++AeoWFo=", "password": "pbkdf2_sha256$260000$9YZSJ0xF4Kvjsm2Fwflciy$zRpcqAMLaJBbqTRS09NkZovOHtcdy6haZxu++AeoWFo=",
"last_login": "2022-03-28T13:05:05.561Z", "last_login": "2022-09-28T12:19:33.698Z",
"is_superuser": false, "is_superuser": false,
"username": "user2", "username": "user2",
"first_name": "User", "first_name": "User",
@ -146,7 +146,7 @@
"pk": 6, "pk": 6,
"fields": { "fields": {
"password": "pbkdf2_sha256$260000$15iUjDNh5gPg5683u1HhOG$fF8hW6AR90o9SCsO/MomzdQFkgQsMUW3YQUlwwiC1vA=", "password": "pbkdf2_sha256$260000$15iUjDNh5gPg5683u1HhOG$fF8hW6AR90o9SCsO/MomzdQFkgQsMUW3YQUlwwiC1vA=",
"last_login": "2021-12-14T19:11:21.048Z", "last_login": "2022-09-06T07:57:19.879Z",
"is_superuser": false, "is_superuser": false,
"username": "worker1", "username": "worker1",
"first_name": "Worker", "first_name": "Worker",
@ -234,7 +234,7 @@
"pk": 10, "pk": 10,
"fields": { "fields": {
"password": "pbkdf2_sha256$260000$X4F89IRqnBtojZuHidrwQG$j1+EpXfyvMesHdod4N+dNUfF4WKS2NWFfeGDec/43as=", "password": "pbkdf2_sha256$260000$X4F89IRqnBtojZuHidrwQG$j1+EpXfyvMesHdod4N+dNUfF4WKS2NWFfeGDec/43as=",
"last_login": "2022-03-05T10:31:48.850Z", "last_login": "2022-09-28T12:17:51.373Z",
"is_superuser": false, "is_superuser": false,
"username": "business1", "username": "business1",
"first_name": "Business", "first_name": "Business",
@ -463,6 +463,14 @@
"expire_date": "2022-03-07T10:37:08.963Z" "expire_date": "2022-03-07T10:37:08.963Z"
} }
}, },
{
"model": "sessions.session",
"pk": "9432vwcpkukpdrme8vipuk9rmt4jv6c8",
"fields": {
"session_data": ".eJxVjDsOwyAQBe9CHSFgxS9l-pwBAbsEJxFIxq6s3D225CJp38y8jYW4LjWsg-YwIbsyyS6_W4r5Re0A-Izt0XnubZmnxA-Fn3Twe0d6307376DGUfdagfWIgEaBQVUKkSAsoIs2lFPOSiRJdrcgOSlcsd6CspS1dsrLpNnnC_apN98:1oVTwm:l7XK3LDGUWfyscT3hQeh8nOj0iu1-oh4NcdaMIoEEYg",
"expire_date": "2022-09-20T08:28:40.843Z"
}
},
{ {
"model": "sessions.session", "model": "sessions.session",
"pk": "9rh2r15lb3xra3kdqjtll5n4zw7ebw95", "pk": "9rh2r15lb3xra3kdqjtll5n4zw7ebw95",
@ -543,6 +551,14 @@
"expire_date": "2022-08-02T14:27:42.020Z" "expire_date": "2022-08-02T14:27:42.020Z"
} }
}, },
{
"model": "sessions.session",
"pk": "o8j1a2nv54lfpfrrg44h5h93jj7xxh60",
"fields": {
"session_data": ".eJxVjMsOwiAQRf-FtSG8EZfu_QYyzIBUDSSlXRn_3TbpQrf3nHPfLMK61LiOPMeJ2IVJdvrdEuAztx3QA9q9c-xtmafEd4UfdPBbp_y6Hu7fQYVRt1pk8rKQdLZQAJc8WqdRepJnkMU5j8qJ4AMFoxUqXaxRIDaEypqiEvt8AeKHN58:1odW3U:8KSfPTBjmZAKj1CGyu6KNB7dVP-lx1qvWe3yAiI0lio",
"expire_date": "2022-10-12T12:20:48.638Z"
}
},
{ {
"model": "sessions.session", "model": "sessions.session",
"pk": "oy4oy702g9qr34fjne8jnxoxvqaiaq26", "pk": "oy4oy702g9qr34fjne8jnxoxvqaiaq26",
@ -577,30 +593,28 @@
}, },
{ {
"model": "sessions.session", "model": "sessions.session",
"pk": "wf6d6vzf4u74l08o0qgbqehei21hibea", "pk": "vph7z81qem6c9705bnj7xxvzmzod24ck",
"fields": { "fields": {
"session_data": ".eJxVjDEOwjAMRe-SGUUkpHZgZO8ZIttxSAG1UtNOiLtDpQ6w_vfef5lE61LT2nROQzYX48zhd2OSh44byHcab5OVaVzmge2m2J02209Zn9fd_Tuo1Oq3DrGwD040Ro_-nJmJgkgsqAAIioCi0KGKMhU4Mgip6wjRF6JyMu8PBAI5Mw:1nIXJc:oovNJRods5cbviWOWush4H3jDdP8XklEignva_EnQ8Q", "session_data": ".eJxVjEEOgjAQRe_StWlmahlal-45A5npFEFNSSisjHdXEha6_e-9_zI9b-vYbzUv_aTmYhDM6XcUTo9cdqJ3LrfZprmsyyR2V-xBq-1mzc_r4f4djFzHbx2G5oxKA4foXCRS9QlEghKDpwBNExWyEw-Z2owOJSTH2IK24smjeX8A-A43ag:1oVRr1:YjNerWaOn53u4nuATnD-oXeI5uqdL6lAyKX4jCG0b94",
"expire_date": "2022-02-25T14:54:28.092Z" "expire_date": "2022-09-20T06:14:35.567Z"
} }
}, },
{ {
"model": "authtoken.token", "model": "sessions.session",
"pk": "2d5bca87ec38cba82ad1da525431ec3a224385b6", "pk": "wf6d6vzf4u74l08o0qgbqehei21hibea",
"fields": { "fields": {
"user": [ "session_data": ".eJxVjDEOwjAMRe-SGUUkpHZgZO8ZIttxSAG1UtNOiLtDpQ6w_vfef5lE61LT2nROQzYX48zhd2OSh44byHcab5OVaVzmge2m2J02209Zn9fd_Tuo1Oq3DrGwD040Ro_-nJmJgkgsqAAIioCi0KGKMhU4Mgip6wjRF6JyMu8PBAI5Mw:1nIXJc:oovNJRods5cbviWOWush4H3jDdP8XklEignva_EnQ8Q",
"admin1" "expire_date": "2022-02-25T14:54:28.092Z"
],
"created": "2022-06-08T08:32:30.149Z"
} }
}, },
{ {
"model": "authtoken.token", "model": "authtoken.token",
"pk": "3952f1aea900fc3daa269473a71c41fac08858b5", "pk": "4f057576712c65d30847e77456aea605a9df5965",
"fields": { "fields": {
"user": [ "user": [
"business1" "admin1"
], ],
"created": "2022-03-05T10:31:48.838Z" "created": "2022-09-28T12:20:48.631Z"
} }
}, },
{ {
@ -811,6 +825,19 @@
"role": "worker" "role": "worker"
} }
}, },
{
"model": "organizations.membership",
"pk": 13,
"fields": {
"user": [
"user5"
],
"organization": 1,
"is_active": true,
"joined_date": "2022-09-28T13:11:37.839Z",
"role": "supervisor"
}
},
{ {
"model": "organizations.invitation", "model": "organizations.invitation",
"pk": "5FjIXya6fTGvlRpauFvi2QN1wDOqo1V9REB5rJinDR8FZO9gr0qmtWpghsCte8Y1", "pk": "5FjIXya6fTGvlRpauFvi2QN1wDOqo1V9REB5rJinDR8FZO9gr0qmtWpghsCte8Y1",
@ -910,6 +937,17 @@
"membership": 9 "membership": 9
} }
}, },
{
"model": "organizations.invitation",
"pk": "hIH9RB3QqZLFwdUDmufaSPc2H8uS5cNjcG6pk8gfAIQ4jg6nJZZWDIQHMN1gFMk9",
"fields": {
"created_date": "2022-09-28T13:11:37.839Z",
"owner": [
"admin1"
],
"membership": 13
}
},
{ {
"model": "organizations.invitation", "model": "organizations.invitation",
"pk": "mFpVV2Yh39uUdU8IpigSxvuPegqi8sjxFi6P9Jdy6fBE8Ky9Juzi1KjeGDQsizSS", "pk": "mFpVV2Yh39uUdU8IpigSxvuPegqi8sjxFi6P9Jdy6fBE8Ky9Juzi1KjeGDQsizSS",
@ -2196,7 +2234,7 @@
"assignee": null, "assignee": null,
"bug_tracker": "", "bug_tracker": "",
"created_date": "2022-06-08T08:32:45.521Z", "created_date": "2022-06-08T08:32:45.521Z",
"updated_date": "2022-06-08T08:33:20.759Z", "updated_date": "2022-09-28T12:26:54.279Z",
"status": "annotation", "status": "annotation",
"organization": 2, "organization": 2,
"source_storage": null, "source_storage": null,
@ -2214,13 +2252,53 @@
"assignee": null, "assignee": null,
"bug_tracker": "", "bug_tracker": "",
"created_date": "2022-09-22T14:21:53.791Z", "created_date": "2022-09-22T14:21:53.791Z",
"updated_date": "2022-09-23T11:57:02.088Z", "updated_date": "2022-09-28T12:26:49.493Z",
"status": "annotation", "status": "annotation",
"organization": 2, "organization": 2,
"source_storage": 5, "source_storage": 5,
"target_storage": 6 "target_storage": 6
} }
}, },
{
"model": "engine.project",
"pk": 6,
"fields": {
"name": "user1_project",
"owner": [
"user1"
],
"assignee": [
"business4"
],
"bug_tracker": "",
"created_date": "2022-09-28T12:15:50.768Z",
"updated_date": "2022-09-28T12:25:54.563Z",
"status": "annotation",
"organization": null,
"source_storage": 9,
"target_storage": 10
}
},
{
"model": "engine.project",
"pk": 7,
"fields": {
"name": "admin1_project",
"owner": [
"admin1"
],
"assignee": [
"worker4"
],
"bug_tracker": "",
"created_date": "2022-09-28T12:26:25.296Z",
"updated_date": "2022-09-28T12:26:29.285Z",
"status": "annotation",
"organization": null,
"source_storage": 11,
"target_storage": 12
}
},
{ {
"model": "engine.task", "model": "engine.task",
"pk": 2, "pk": 2,
@ -3794,6 +3872,30 @@
"parent": 22 "parent": 22
} }
}, },
{
"model": "engine.label",
"pk": 27,
"fields": {
"task": null,
"project": 6,
"name": "label_0",
"color": "#bde94a",
"type": "any",
"parent": null
}
},
{
"model": "engine.label",
"pk": 28,
"fields": {
"task": null,
"project": 7,
"name": "label_0",
"color": "#bde94a",
"type": "any",
"parent": null
}
},
{ {
"model": "engine.skeleton", "model": "engine.skeleton",
"pk": 1, "pk": 1,
@ -5647,6 +5749,167 @@
"cloud_storage_id": null "cloud_storage_id": null
} }
}, },
{
"model": "engine.storage",
"pk": 9,
"fields": {
"location": "local",
"cloud_storage_id": null
}
},
{
"model": "engine.storage",
"pk": 10,
"fields": {
"location": "local",
"cloud_storage_id": null
}
},
{
"model": "engine.storage",
"pk": 11,
"fields": {
"location": "local",
"cloud_storage_id": null
}
},
{
"model": "engine.storage",
"pk": 12,
"fields": {
"location": "local",
"cloud_storage_id": null
}
},
{
"model": "webhooks.webhook",
"pk": 1,
"fields": {
"target_url": "http://example.com/",
"description": "",
"events": "delete:task,update:task,create:task,update:job",
"type": "project",
"content_type": "application/json",
"secret": "",
"is_active": true,
"enable_ssl": true,
"created_date": "2022-09-28T12:16:28.311Z",
"updated_date": "2022-09-28T12:16:28.311Z",
"owner": [
"user1"
],
"project": 6,
"organization": null
}
},
{
"model": "webhooks.webhook",
"pk": 2,
"fields": {
"target_url": "http://example.com/",
"description": "",
"events": "delete:comment,update:issue,update:comment,update:job,update:project,delete:task,create:comment,delete:issue,update:task,create:task,create:issue",
"type": "project",
"content_type": "application/json",
"secret": "",
"is_active": true,
"enable_ssl": true,
"created_date": "2022-09-28T12:18:12.412Z",
"updated_date": "2022-09-28T12:18:12.412Z",
"owner": [
"business1"
],
"project": 1,
"organization": null
}
},
{
"model": "webhooks.webhook",
"pk": 3,
"fields": {
"target_url": "http://example.com",
"description": "",
"events": "update:issue,delete:issue,create:issue,update:project",
"type": "project",
"content_type": "application/json",
"secret": "",
"is_active": true,
"enable_ssl": true,
"created_date": "2022-09-28T12:19:49.744Z",
"updated_date": "2022-09-28T12:19:49.744Z",
"owner": [
"user2"
],
"project": 3,
"organization": 2
}
},
{
"model": "webhooks.webhook",
"pk": 5,
"fields": {
"target_url": "http://example.com",
"description": "",
"events": "delete:invitation,delete:project,create:project,delete:comment,update:organization,update:issue,update:task,update:comment,update:job,update:project,delete:task,create:comment,delete:issue,delete:membership,create:invitation,create:task,create:issue,update:membership",
"type": "organization",
"content_type": "application/json",
"secret": "",
"is_active": true,
"enable_ssl": true,
"created_date": "2022-09-28T12:51:06.703Z",
"updated_date": "2022-09-28T12:51:06.703Z",
"owner": [
"admin1"
],
"project": null,
"organization": 1
}
},
{
"model": "webhooks.webhookdelivery",
"pk": 8,
"fields": {
"webhook": 5,
"event": "create:invitation",
"status_code": 200,
"redelivery": false,
"created_date": "2022-09-28T13:11:37.850Z",
"updated_date": "2022-09-28T13:11:38.311Z",
"changed_fields": "",
"request": {
"event": "create:invitation",
"sender": {
"id": 1,
"url": "http://localhost:8080/api/users/1",
"username": "admin1",
"last_name": "First",
"first_name": "Admin"
},
"invitation": {
"key": "hIH9RB3QqZLFwdUDmufaSPc2H8uS5cNjcG6pk8gfAIQ4jg6nJZZWDIQHMN1gFMk9",
"role": "supervisor",
"user": {
"id": 19,
"url": "http://localhost:8080/api/users/19",
"username": "user5",
"last_name": "Fifth",
"first_name": "User"
},
"owner": {
"id": 1,
"url": "http://localhost:8080/api/users/1",
"username": "admin1",
"last_name": "First",
"first_name": "Admin"
},
"created_date": "2022-09-28T13:11:37.839853Z",
"organization": 1
},
"webhook_id": 5
},
"response": "<!doctype html>\n<html>\n<head>\n <title>Example Domain</title>\n\n <meta charset=\"utf-8\" />\n <meta http-equiv=\"Content-type\" content=\"text/html; charset=utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <style type=\"text/css\">\n body {\n background-color: #f0f0f2;\n margin: 0;\n padding: 0;\n font-family: -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", \"Open Sans\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n \n }\n div {\n width: 600px;\n margin: 5em auto;\n padding: 2em;\n background-color: #fdfdff;\n border-radius: 0.5em;\n box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);\n }\n a:link, a:visited {\n color: #38488f;\n text-decoration: none;\n }\n @media (max-width: 700px) {\n div {\n margin: 0 auto;\n width: auto;\n }\n }\n </style> \n</head>\n\n<body>\n<div>\n <h1>Example Domain</h1>\n <p>This domain is for use in illustrative examples in documents. You may use this\n domain in literature without prior coordination or asking for permission.</p>\n <p><a href=\"https://www.iana.org/domains/example\">More information...</a></p>\n</div>\n</body>\n</html>\n"
}
},
{ {
"model": "admin.logentry", "model": "admin.logentry",
"pk": 1, "pk": 1,

@ -1,8 +1,28 @@
{ {
"count": 10, "count": 11,
"next": null, "next": null,
"previous": null, "previous": null,
"results": [ "results": [
{
"created_date": "2022-09-28T13:11:37.839000Z",
"key": "hIH9RB3QqZLFwdUDmufaSPc2H8uS5cNjcG6pk8gfAIQ4jg6nJZZWDIQHMN1gFMk9",
"organization": 1,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"role": "supervisor",
"user": {
"first_name": "User",
"id": 19,
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
}
},
{ {
"created_date": "2022-02-24T21:29:21.978000Z", "created_date": "2022-02-24T21:29:21.978000Z",
"key": "Fi3WRUhFxTWpMiVpdwNR2CGyhgcIXSCUYgPCugPq72QUOgHz9NSMOGiKS3PfJ7Ql", "key": "Fi3WRUhFxTWpMiVpdwNR2CGyhgcIXSCUYgPCugPq72QUOgHz9NSMOGiKS3PfJ7Ql",

@ -1,8 +1,23 @@
{ {
"count": 12, "count": 13,
"next": null, "next": null,
"previous": null, "previous": null,
"results": [ "results": [
{
"id": 13,
"invitation": "hIH9RB3QqZLFwdUDmufaSPc2H8uS5cNjcG6pk8gfAIQ4jg6nJZZWDIQHMN1gFMk9",
"is_active": true,
"joined_date": "2022-09-28T13:11:37.839000Z",
"organization": 1,
"role": "supervisor",
"user": {
"first_name": "User",
"id": 19,
"last_name": "Fifth",
"url": "http://localhost:8080/api/users/19",
"username": "user5"
}
},
{ {
"id": 12, "id": 12,
"invitation": "Fi3WRUhFxTWpMiVpdwNR2CGyhgcIXSCUYgPCugPq72QUOgHz9NSMOGiKS3PfJ7Ql", "invitation": "Fi3WRUhFxTWpMiVpdwNR2CGyhgcIXSCUYgPCugPq72QUOgHz9NSMOGiKS3PfJ7Ql",

@ -1,8 +1,104 @@
{ {
"count": 5, "count": 7,
"next": null, "next": null,
"previous": null, "previous": null,
"results": [ "results": [
{
"assignee": {
"first_name": "Worker",
"id": 9,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/9",
"username": "worker4"
},
"bug_tracker": "",
"created_date": "2022-09-28T12:26:25.296000Z",
"dimension": null,
"id": 7,
"labels": [
{
"attributes": [],
"color": "#bde94a",
"has_parent": false,
"id": 28,
"name": "label_0",
"sublabels": [],
"type": "any"
}
],
"name": "admin1_project",
"organization": null,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 11,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 12,
"location": "local"
},
"task_subsets": [],
"tasks": [],
"updated_date": "2022-09-28T12:26:29.285000Z",
"url": "http://localhost:8080/api/projects/7"
},
{
"assignee": {
"first_name": "Business",
"id": 13,
"last_name": "Fourth",
"url": "http://localhost:8080/api/users/13",
"username": "business4"
},
"bug_tracker": "",
"created_date": "2022-09-28T12:15:50.768000Z",
"dimension": null,
"id": 6,
"labels": [
{
"attributes": [],
"color": "#bde94a",
"has_parent": false,
"id": 27,
"name": "label_0",
"sublabels": [],
"type": "any"
}
],
"name": "user1_project",
"organization": null,
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"source_storage": {
"cloud_storage_id": null,
"id": 9,
"location": "local"
},
"status": "annotation",
"target_storage": {
"cloud_storage_id": null,
"id": 10,
"location": "local"
},
"task_subsets": [],
"tasks": [],
"updated_date": "2022-09-28T12:25:54.563000Z",
"url": "http://localhost:8080/api/projects/6"
},
{ {
"assignee": null, "assignee": null,
"bug_tracker": "", "bug_tracker": "",
@ -212,7 +308,7 @@
"tasks": [ "tasks": [
14 14
], ],
"updated_date": "2022-09-23T11:57:02.088000Z", "updated_date": "2022-09-28T12:26:49.493000Z",
"url": "http://localhost:8080/api/projects/5" "url": "http://localhost:8080/api/projects/5"
}, },
{ {
@ -257,7 +353,7 @@
"tasks": [ "tasks": [
13 13
], ],
"updated_date": "2022-06-08T08:33:20.759000Z", "updated_date": "2022-09-28T12:26:54.279000Z",
"url": "http://localhost:8080/api/projects/4" "url": "http://localhost:8080/api/projects/4"
}, },
{ {

@ -166,7 +166,7 @@
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": "2022-03-05T10:31:48.850000Z", "last_login": "2022-09-28T12:17:51.373000Z",
"last_name": "First", "last_name": "First",
"url": "http://localhost:8080/api/users/10", "url": "http://localhost:8080/api/users/10",
"username": "business1" "username": "business1"
@ -230,7 +230,7 @@
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": "2021-12-14T19:11:21.048000Z", "last_login": "2022-09-06T07:57:19.879000Z",
"last_name": "First", "last_name": "First",
"url": "http://localhost:8080/api/users/6", "url": "http://localhost:8080/api/users/6",
"username": "worker1" "username": "worker1"
@ -278,7 +278,7 @@
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": "2022-03-28T13:05:05.561000Z", "last_login": "2022-09-28T12:19:33.698000Z",
"last_name": "Second", "last_name": "Second",
"url": "http://localhost:8080/api/users/3", "url": "http://localhost:8080/api/users/3",
"username": "user2" "username": "user2"
@ -294,7 +294,7 @@
"is_active": true, "is_active": true,
"is_staff": false, "is_staff": false,
"is_superuser": false, "is_superuser": false,
"last_login": "2022-03-17T07:22:09.327000Z", "last_login": "2022-09-28T12:15:35.182000Z",
"last_name": "First", "last_name": "First",
"url": "http://localhost:8080/api/users/2", "url": "http://localhost:8080/api/users/2",
"username": "user1" "username": "user1"
@ -310,7 +310,7 @@
"is_active": true, "is_active": true,
"is_staff": true, "is_staff": true,
"is_superuser": true, "is_superuser": true,
"last_login": "2022-09-22T14:21:28.429000Z", "last_login": "2022-09-28T12:20:48.633000Z",
"last_name": "First", "last_name": "First",
"url": "http://localhost:8080/api/users/1", "url": "http://localhost:8080/api/users/1",
"username": "admin1" "username": "admin1"

@ -0,0 +1,138 @@
{
"count": 4,
"next": null,
"previous": null,
"results": [
{
"content_type": "application/json",
"created_date": "2022-09-28T12:51:06.703000Z",
"description": "",
"enable_ssl": true,
"events": [
"create:comment",
"create:invitation",
"create:issue",
"create:project",
"create:task",
"delete:comment",
"delete:invitation",
"delete:issue",
"delete:membership",
"delete:project",
"delete:task",
"update:comment",
"update:issue",
"update:job",
"update:membership",
"update:organization",
"update:project",
"update:task"
],
"id": 5,
"is_active": true,
"last_delivery_date": "2022-09-28T13:11:38.311000Z",
"last_status": 200,
"organization": 1,
"owner": {
"first_name": "Admin",
"id": 1,
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"username": "admin1"
},
"project": null,
"target_url": "http://example.com",
"type": "organization",
"updated_date": "2022-09-28T12:51:06.703000Z",
"url": "http://localhost:8080/api/webhooks/5"
},
{
"content_type": "application/json",
"created_date": "2022-09-28T12:19:49.744000Z",
"description": "",
"enable_ssl": true,
"events": [
"create:issue",
"delete:issue",
"update:issue",
"update:project"
],
"id": 3,
"is_active": true,
"organization": 2,
"owner": {
"first_name": "User",
"id": 3,
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
},
"project": 3,
"target_url": "http://example.com",
"type": "project",
"updated_date": "2022-09-28T12:19:49.744000Z",
"url": "http://localhost:8080/api/webhooks/3"
},
{
"content_type": "application/json",
"created_date": "2022-09-28T12:18:12.412000Z",
"description": "",
"enable_ssl": true,
"events": [
"create:comment",
"create:issue",
"create:task",
"delete:comment",
"delete:issue",
"delete:task",
"update:comment",
"update:issue",
"update:job",
"update:project",
"update:task"
],
"id": 2,
"is_active": true,
"organization": null,
"owner": {
"first_name": "Business",
"id": 10,
"last_name": "First",
"url": "http://localhost:8080/api/users/10",
"username": "business1"
},
"project": 1,
"target_url": "http://example.com/",
"type": "project",
"updated_date": "2022-09-28T12:18:12.412000Z",
"url": "http://localhost:8080/api/webhooks/2"
},
{
"content_type": "application/json",
"created_date": "2022-09-28T12:16:28.311000Z",
"description": "",
"enable_ssl": true,
"events": [
"create:task",
"delete:task",
"update:job",
"update:task"
],
"id": 1,
"is_active": true,
"organization": null,
"owner": {
"first_name": "User",
"id": 2,
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
},
"project": 6,
"target_url": "http://example.com/",
"type": "project",
"updated_date": "2022-09-28T12:16:28.311000Z",
"url": "http://localhost:8080/api/webhooks/1"
}
]
}

@ -83,6 +83,11 @@ def issues():
with open(osp.join(ASSETS_DIR, 'issues.json')) as f: with open(osp.join(ASSETS_DIR, 'issues.json')) as f:
return Container(json.load(f)['results']) return Container(json.load(f)['results'])
@pytest.fixture(scope='session')
def webhooks():
with open(osp.join(ASSETS_DIR, 'webhooks.json')) as f:
return Container(json.load(f)['results'])
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def users_by_name(users): def users_by_name(users):
return {user['username']: user for user in users} return {user['username']: user for user in users}

@ -27,7 +27,11 @@ CONTAINER_NAME_FILES = [
DC_FILES = [ DC_FILES = [
osp.join(CVAT_ROOT_DIR, dc_file) osp.join(CVAT_ROOT_DIR, dc_file)
for dc_file in ("docker-compose.dev.yml", "tests/docker-compose.minio.yml") for dc_file in (
"docker-compose.dev.yml",
"tests/docker-compose.minio.yml",
"tests/docker-compose.webhook.yml"
)
] + CONTAINER_NAME_FILES ] + CONTAINER_NAME_FILES
@ -208,7 +212,9 @@ def start_services(rebuild=False):
) )
_run( _run(
f"docker-compose -p {PREFIX} -f {' -f '.join(DC_FILES)} up -d " f"docker-compose -p {PREFIX} "
+ "--env-file " + osp.join(CVAT_ROOT_DIR, "tests", "python", "webhook_receiver", ".env")
+ f" -f {' -f '.join(DC_FILES)} up -d "
+ "--build" * rebuild, + "--build" * rebuild,
capture_output=False, capture_output=False,
) )
@ -251,7 +257,9 @@ def services(request):
if stop: if stop:
_run( _run(
f"docker-compose -p {PREFIX} -f {' -f '.join(DC_FILES)} down -v", f"docker-compose -p {PREFIX} "
+ "--env-file " + osp.join(CVAT_ROOT_DIR, "tests", "python", "webhook_receiver", ".env")
+ f" -f {' -f '.join(DC_FILES)} down -v",
capture_output=False, capture_output=False,
) )
pytest.exit("All testing containers are stopped", returncode=0) pytest.exit("All testing containers are stopped", returncode=0)

@ -9,7 +9,7 @@ import json
if __name__ == '__main__': if __name__ == '__main__':
annotations = {} annotations = {}
for obj in ['user', 'project', 'task', 'job', 'organization', 'membership', for obj in ['user', 'project', 'task', 'job', 'organization', 'membership',
'invitation', 'cloudstorage', 'issue']: 'invitation', 'cloudstorage', 'issue', 'webhook']:
response = get_method('admin1', f'{obj}s', page_size='all') response = get_method('admin1', f'{obj}s', page_size='all')
with open(osp.join(ASSETS_DIR, f'{obj}s.json'), 'w') as f: with open(osp.join(ASSETS_DIR, f'{obj}s.json'), 'w') as f:
json.dump(response.json(), f, indent=2, sort_keys=True) json.dump(response.json(), f, indent=2, sort_keys=True)

@ -0,0 +1,2 @@
SERVER_PORT=2020
PAYLOAD_ENDPOINT=payload

@ -0,0 +1,33 @@
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
import re
import os
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
class RequestHandler(BaseHTTPRequestHandler):
def do_POST(self):
TARGET_URL_PATTERN = re.compile(r"/" + os.getenv("PAYLOAD_ENDPOINT"))
if not re.search(TARGET_URL_PATTERN, self.path):
return
self.send_response(HTTPStatus.OK)
self.end_headers()
request_body = self.rfile.read(int(self.headers["content-length"]))
self.wfile.write(request_body)
def main():
TARGET_HOST = "0.0.0.0"
TARGET_PORT = int(os.getenv("SERVER_PORT"))
webhook_receiver = HTTPServer((TARGET_HOST, TARGET_PORT), RequestHandler)
webhook_receiver.serve_forever()
if __name__ == "__main__":
main()

@ -4644,11 +4644,6 @@ eslint-plugin-cypress@^2.11.2:
dependencies: dependencies:
globals "^11.12.0" globals "^11.12.0"
eslint-plugin-header@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz#6ce512432d57675265fac47292b50d1eff11acd6"
integrity sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==
eslint-plugin-import@^2.22.1: eslint-plugin-import@^2.22.1:
version "2.26.0" version "2.26.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b"

Loading…
Cancel
Save