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',
'lint-staged.config.js',
],
plugins: ['@typescript-eslint', 'security', 'no-unsanitized', 'eslint-plugin-header', 'import'],
plugins: ['@typescript-eslint', 'security', 'no-unsanitized', 'import'],
extends: [
'eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM',
'airbnb-base', 'plugin:import/errors', 'plugin:import/warnings',

@ -124,6 +124,25 @@
"env": {},
"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",
"type": "python",
@ -285,6 +304,7 @@
"server: django",
"server: RQ - default",
"server: RQ - low",
"server: RQ - webhooks",
"server: RQ - scheduler",
"server: git",
]

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -17,13 +18,14 @@ const config = require('./config');
checkObjectType,
} = require('./common');
const User = require('./user');
const User = require('./user').default;
const { AnnotationFormats } = require('./annotation-formats');
const { ArgumentError } = require('./exceptions');
const { Task, Job } = require('./session');
const Project = require('./project').default;
const { CloudStorage } = require('./cloud-storage');
const Organization = require('./organization');
const Webhook = require('./webhook').default;
function implementAPI(cvat) {
cvat.plugins.list.implementation = PluginRegistry.list;
@ -286,6 +288,39 @@ const config = require('./config');
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;
}

@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -23,6 +24,7 @@ function build() {
const { FrameData } = require('./frames');
const { CloudStorage } = require('./cloud-storage');
const Organization = require('./organization');
const Webhook = require('./webhook').default;
const enums = require('./enums');
@ -30,7 +32,7 @@ function build() {
Exception, ArgumentError, DataError, ScriptingError, PluginError, ServerError,
} = require('./exceptions');
const User = require('./user');
const User = require('./user').default;
const pjson = require('../package.json');
const config = require('./config');
@ -843,6 +845,26 @@ function build() {
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 classes
@ -864,6 +886,7 @@ function build() {
FrameData,
CloudStorage,
Organization,
Webhook,
},
};

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

@ -438,3 +438,29 @@ export enum StorageLocation {
LOCAL = 'local',
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) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -6,7 +7,7 @@ const quickhull = require('quickhull');
const PluginRegistry = require('./plugins').default;
const Comment = require('./comment');
const User = require('./user');
const User = require('./user').default;
const { ArgumentError } = require('./exceptions');
const serverProxy = require('./server-proxy').default;

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

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

@ -3,7 +3,7 @@
//
// SPDX-License-Identifier: MIT
import { StorageLocation } from './enums';
import { StorageLocation, WebhookSourceType } from './enums';
import { Storage } from './storage';
type Params = {
@ -18,12 +18,11 @@ type Params = {
const FormData = require('form-data');
const store = require('store');
const Axios = require('axios');
const tus = require('tus-js-client');
const config = require('./config');
const DownloadWorker = require('./download.worker');
const { ServerError } = require('./exceptions');
const Axios = require('axios');
const tus = require('tus-js-client');
function enableOrganization() {
return { org: config.organizationID || '' };
@ -921,8 +920,8 @@ class ServerProxy {
}
setTimeout(request);
})
};
});
}
const isCloudStorage = storage.location === StorageLocation.CLOUD_STORAGE;
@ -2022,11 +2021,160 @@ class ServerProxy {
response = await Axios.get(`${backendAPI}/invitations/${id}`, {
proxy: config.proxy,
});
return response.data;
} catch (errorData) {
throw generateError(errorData);
}
}
return response.data;
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;
} 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(
@ -2189,6 +2337,18 @@ class ServerProxy {
}),
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,
} = require('./enums');
const { Label } = require('./labels');
const User = require('./user');
const User = require('./user').default;
const Issue = require('./issue');
const { FieldUpdateTrigger, checkObjectType } = require('./common');

@ -1,180 +1,200 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
(() => {
/**
* Class representing a user
* @memberof module:API.cvat.classes
* @hideconstructor
*/
class User {
constructor(initialData) {
const data = {
id: null,
username: null,
email: null,
first_name: null,
last_name: null,
groups: null,
last_login: null,
date_joined: null,
is_staff: null,
is_superuser: null,
is_active: null,
email_verification_required: null,
};
interface RawUserData {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
groups: string[];
last_login: string;
date_joined: string;
is_staff: boolean;
is_superuser: boolean;
is_active: boolean;
email_verification_required: boolean;
}
for (const property in data) {
if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) {
data[property] = initialData[property];
}
}
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;
Object.defineProperties(
this,
Object.freeze({
id: {
/**
* @name id
* @type {number}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.id,
},
username: {
/**
* @name username
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.username,
},
email: {
/**
* @name email
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.email,
},
firstName: {
/**
* @name firstName
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.first_name,
},
lastName: {
/**
* @name lastName
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.last_name,
},
groups: {
/**
* @name groups
* @type {string[]}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => JSON.parse(JSON.stringify(data.groups)),
},
lastLogin: {
/**
* @name lastLogin
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.last_login,
},
dateJoined: {
/**
* @name dateJoined
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.date_joined,
},
isStaff: {
/**
* @name isStaff
* @type {boolean}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.is_staff,
},
isSuperuser: {
/**
* @name isSuperuser
* @type {boolean}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.is_superuser,
},
isActive: {
/**
* @name isActive
* @type {boolean}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.is_active,
},
isVerified: {
/**
* @name isVerified
* @type {boolean}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => !data.email_verification_required,
},
}),
);
}
constructor(initialData: RawUserData) {
const data = {
id: null,
username: null,
email: null,
first_name: null,
last_name: null,
groups: null,
last_login: null,
date_joined: null,
is_staff: null,
is_superuser: null,
is_active: null,
email_verification_required: null,
};
serialize() {
return {
id: this.id,
username: this.username,
email: this.email,
first_name: this.firstName,
last_name: this.lastName,
groups: this.groups,
last_login: this.lastLogin,
date_joined: this.dateJoined,
is_staff: this.isStaff,
is_superuser: this.isSuperuser,
is_active: this.isActive,
email_verification_required: this.isVerified,
};
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: {
/**
* @name id
* @type {number}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.id,
},
username: {
/**
* @name username
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.username,
},
email: {
/**
* @name email
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.email,
},
firstName: {
/**
* @name firstName
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.first_name,
},
lastName: {
/**
* @name lastName
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.last_name,
},
groups: {
/**
* @name groups
* @type {string[]}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => JSON.parse(JSON.stringify(data.groups)),
},
lastLogin: {
/**
* @name lastLogin
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.last_login,
},
dateJoined: {
/**
* @name dateJoined
* @type {string}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.date_joined,
},
isStaff: {
/**
* @name isStaff
* @type {boolean}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.is_staff,
},
isSuperuser: {
/**
* @name isSuperuser
* @type {boolean}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.is_superuser,
},
isActive: {
/**
* @name isActive
* @type {boolean}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => data.is_active,
},
isVerified: {
/**
* @name isVerified
* @type {boolean}
* @memberof module:API.cvat.classes.User
* @readonly
* @instance
*/
get: () => !data.email_verification_required,
},
}),
);
}
module.exports = User;
})();
serialize(): RawUserData {
return {
id: this.id,
username: this.username,
email: this.email,
first_name: this.firstName,
last_name: this.lastName,
groups: this.groups,
last_login: this.lastLogin,
date_joined: this.dateJoined,
is_staff: this.isStaff,
is_superuser: this.isSuperuser,
is_active: this.isActive,
email_verification_required: this.isVerified,
};
}
}

@ -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
window.cvat = require('../../src/api');
const User = require('../../src/user');
const User = require('../../src/user').default;
// Test cases
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,
frame: 0,
label_id:59,
label_id: 59,
group: 0,
source: 'manual',
attributes: []
@ -2989,7 +2989,7 @@ const frameMetaDummyData = {
start_frame: 0,
stop_frame: 8,
frame_filter: '',
deleted_frames: [7,8],
deleted_frames: [7, 8],
frames: [
{
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 = {
tasksDummyData,
projectsDummyData,
@ -3293,4 +3452,6 @@ module.exports = {
frameMetaDummyData,
formatsDummyData,
cloudStoragesDummyData,
webhooksDummyData,
webhooksEventsDummyData,
};

@ -13,6 +13,8 @@ const {
jobAnnotationsDummyData,
frameMetaDummyData,
cloudStoragesDummyData,
webhooksDummyData,
webhooksEventsDummyData,
} = require('./dummy-data.mock');
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(
this,
Object.freeze({
@ -489,6 +556,17 @@ class ServerProxy {
}),
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 { 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 { getCore } from 'cvat-core-wrapper';
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/create' component={CreateProjectPageComponent} />
<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/create' component={CreateTaskPageContainer} />
<Route exact path='/tasks/:id' component={TaskPageContainer} />
@ -391,6 +396,9 @@ class CVATApplication extends React.PureComponent<CVATAppProps & RouteComponentP
path='/organizations/create'
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} />
{isModelPluginActive && (
<Route exact path='/models' component={ModelsPageContainer} />

@ -227,7 +227,7 @@ function HeaderContainer(props: Props): JSX.Element {
const resetOrganization = (): void => {
localStorage.removeItem('currentOrganization');
if (/\d+$/.test(window.location.pathname)) {
if (/(webhooks)|(\d+)/.test(window.location.pathname)) {
window.location.pathname = '/';
} else {
window.location.reload();
@ -237,7 +237,7 @@ function HeaderContainer(props: Props): JSX.Element {
const setNewOrganization = (organization: any): void => {
if (!currentOrganization || currentOrganization.slug !== 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.)
window.location.pathname = '/';
} else {

@ -1,4 +1,5 @@
// Copyright (C) 2021-2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -148,3 +149,7 @@
.cvat-organization-invitation-field {
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) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -13,11 +14,14 @@ import Space from 'antd/lib/space';
import Input from 'antd/lib/input';
import Form from 'antd/lib/form';
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 { Store } from 'antd/lib/form/interface';
import {
EditTwoTone, EnvironmentOutlined,
MailOutlined, PhoneOutlined, PlusCircleOutlined, DeleteOutlined,
MailOutlined, PhoneOutlined, PlusCircleOutlined, DeleteOutlined, MoreOutlined,
} from '@ant-design/icons';
import {
@ -26,6 +30,7 @@ import {
removeOrganizationAsync,
updateOrganizationAsync,
} from 'actions/organization-actions';
import { useHistory } from 'react-router-dom';
export interface Props {
organizationInstance: any;
@ -33,6 +38,11 @@ export interface Props {
fetchMembers: () => void;
}
export enum MenuActions {
SET_WEBHOOKS = 'SET_WEBHOOKS',
REMOVE_ORGANIZATION = 'REMOVE_ORGANIZATION',
}
function OrganizationTopBar(props: Props): JSX.Element {
const { organizationInstance, userInstance, fetchMembers } = props;
const {
@ -62,14 +72,86 @@ function OrganizationTopBar(props: Props): JSX.Element {
let organizationName = name;
let organizationDescription = description;
let organizationContacts = contact;
const history = useHistory();
return (
<>
<Row justify='space-between'>
<Col span={24}>
<div className='cvat-organization-top-bar-descriptions'>
<Text>
<Text className='cvat-title'>{`Organization: ${slug} `}</Text>
</Text>
<Row justify='space-between'>
<Col>
<Text>
<Text className='cvat-title'>{`Organization: ${slug} `}</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
editable={{
onChange: (value: string) => {
@ -213,44 +295,6 @@ function OrganizationTopBar(props: Props): JSX.Element {
Leave organization
</Button>
) : 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
type='primary'
onClick={() => setVisibleInviteModal(true)}

@ -12,6 +12,7 @@ import { CombinedState } from 'reducers';
import { deleteProjectAsync } from 'actions/projects-actions';
import { exportActions } from 'actions/export-actions';
import { importActions } from 'actions/import-actions';
import { useHistory } from 'react-router';
interface Props {
projectInstance: any;
@ -20,6 +21,7 @@ interface Props {
export default function ProjectActionsMenuComponent(props: Props): JSX.Element {
const { projectInstance } = props;
const history = useHistory();
const dispatch = useDispatch();
const exportBackupIsActive = useSelector((state: CombinedState) => (
state.export.projects.backup.current[projectInstance.id]
@ -56,6 +58,20 @@ export default function ProjectActionsMenuComponent(props: Props): JSX.Element {
>
Backup Project
</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.Item key='delete' onClick={onDeleteProject}>
Delete

@ -1,4 +1,5 @@
// Copyright (C) 2022 Intel Corporation
// Copyright (C) 2022 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@ -21,11 +22,11 @@ import { CombinedState } from 'reducers';
import { User } from 'components/task-page/user-selector';
interface ResourceFilterProps {
predefinedVisible: boolean;
predefinedVisible?: boolean;
recentVisible: boolean;
builderVisible: boolean;
value: string | null;
onPredefinedVisibleChange(visible: boolean): void;
onPredefinedVisibleChange?: (visible: boolean) => void;
onBuilderVisibleChange(visible: boolean): void;
onRecentVisibleChange(visible: boolean): void;
onApplyFilter(filter: string | null): void;
@ -35,7 +36,7 @@ export default function ResourceFilterHOC(
filtrationCfg: Partial<Config>,
localStorageRecentKeyword: string,
localStorageRecentCapacity: number,
predefinedFilterValues: Record<string, string>,
predefinedFilterValues?: Record<string, string>,
): React.FunctionComponent<ResourceFilterProps> {
const config: Config = { ...AntdConfig, ...filtrationCfg };
const defaultTree = QbUtils.checkTree(
@ -100,9 +101,10 @@ export default function ResourceFilterHOC(
return filters[0];
}
function getPredefinedFilters(user: User): Record<string, string> {
const result: Record<string, string> = {};
if (user) {
function getPredefinedFilters(user: User): Record<string, string> | null {
let result: Record <string, string> | null = null;
if (user && predefinedFilterValues) {
result = {};
for (const key of Object.keys(predefinedFilterValues)) {
result[key] = predefinedFilterValues[key].replace('<username>', `${user.username}`);
}
@ -190,50 +192,54 @@ export default function ResourceFilterHOC(
const predefinedFilters = getPredefinedFilters(user);
return (
<div className='cvat-resource-page-filters'>
<Dropdown
destroyPopupOnHide
visible={predefinedVisible}
placement='bottomLeft'
overlay={(
<div className='cvat-resource-page-predefined-filters-list'>
{Object.keys(predefinedFilters).map((key: string): JSX.Element => (
<Checkbox
checked={appliedFilter.predefined?.includes(predefinedFilters[key])}
onChange={(event: CheckboxChangeEvent) => {
let updatedValue: string[] | null = appliedFilter.predefined || [];
if (event.target.checked) {
updatedValue.push(predefinedFilters[key]);
} else {
updatedValue = updatedValue
.filter((appliedValue: string) => (
appliedValue !== predefinedFilters[key]
));
}
{
predefinedFilters && onPredefinedVisibleChange ? (
<Dropdown
destroyPopupOnHide
visible={predefinedVisible}
placement='bottomLeft'
overlay={(
<div className='cvat-resource-page-predefined-filters-list'>
{Object.keys(predefinedFilters).map((key: string): JSX.Element => (
<Checkbox
checked={appliedFilter.predefined?.includes(predefinedFilters[key])}
onChange={(event: CheckboxChangeEvent) => {
let updatedValue: string[] | null = appliedFilter.predefined || [];
if (event.target.checked) {
updatedValue.push(predefinedFilters[key]);
} else {
updatedValue = updatedValue
.filter((appliedValue: string) => (
appliedValue !== predefinedFilters[key]
));
}
if (!updatedValue.length) {
updatedValue = null;
}
if (!updatedValue.length) {
updatedValue = null;
}
setAppliedFilter({
...defaultAppliedFilter,
predefined: updatedValue,
});
}}
key={key}
>
{key}
</Checkbox>
)) }
</div>
)}
>
<Button type='default' onClick={() => onPredefinedVisibleChange(!predefinedVisible)}>
Quick filters
{ appliedFilter.predefined ?
<FilterFilled /> :
<FilterOutlined />}
</Button>
</Dropdown>
setAppliedFilter({
...defaultAppliedFilter,
predefined: updatedValue,
});
}}
key={key}
>
{key}
</Checkbox>
)) }
</div>
)}
>
<Button type='default' onClick={() => onPredefinedVisibleChange(!predefinedVisible)}>
Quick filters
{ appliedFilter.predefined ?
<FilterFilled /> :
<FilterOutlined />}
</Button>
</Dropdown>
) : null
}
<Dropdown
placement='bottomRight'
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 ObjectState from 'cvat-core/src/object-state';
import Webhook from 'cvat-core/src/webhook';
import {
Label, Attribute, RawAttribute, RawLabel,
} from 'cvat-core/src/labels';
@ -28,6 +29,7 @@ export {
Attribute,
ShapeType,
Storage,
Webhook,
};
export type {

@ -6,6 +6,7 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { Canvas3d } from 'cvat-canvas3d/src/typescript/canvas3d';
import { Canvas, RectDrawingMethod, CuboidDrawingMethod } from 'cvat-canvas-wrapper';
import { Webhook } from 'cvat-core-wrapper';
import { IntelligentScissors } from 'utils/opencv-wrapper/intelligent-scissors';
import { KeyMap } from 'utils/mousetrap-react';
import { OpenCVTracker } from 'utils/opencv-wrapper/opencv-interfaces';
@ -510,6 +511,12 @@ export interface NotificationsState {
updatingMembership: null | ErrorState;
removingMembership: null | ErrorState;
};
webhooks: {
fetching: null | ErrorState;
creating: null | ErrorState;
updating: null | ErrorState;
deleting: null | ErrorState;
};
};
messages: {
tasks: {
@ -837,6 +844,22 @@ export interface OrganizationState {
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 {
auth: AuthState;
projects: ProjectsState;
@ -857,6 +880,7 @@ export interface CombinedState {
import: ImportState;
cloudStorages: CloudStoragesState;
organizations: OrganizationState;
webhooks: WebhooksState;
}
export enum DimensionType {

@ -22,6 +22,7 @@ import { ImportActionTypes } from 'actions/import-actions';
import { CloudStorageActionTypes } from 'actions/cloud-storage-actions';
import { OrganizationActionsTypes } from 'actions/organization-actions';
import { JobsActionTypes } from 'actions/jobs-actions';
import { WebhooksActionsTypes } from 'actions/webhooks-actions';
import { NotificationsState } from '.';
@ -150,6 +151,12 @@ const defaultState: NotificationsState = {
updatingMembership: null,
removingMembership: null,
},
webhooks: {
fetching: null,
creating: null,
updating: null,
deleting: null,
},
},
messages: {
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 AuthActionTypes.LOGOUT_SUCCESS: {
return { ...defaultState };

@ -23,6 +23,7 @@ import exportReducer from './export-reducer';
import importReducer from './import-reducer';
import cloudStoragesReducer from './cloud-storages-reducer';
import organizationsReducer from './organizations-reducer';
import webhooksReducer from './webhooks-reducer';
export default function createRootReducer(): Reducer {
return combineReducers({
@ -45,5 +46,6 @@ export default function createRootReducer(): Reducer {
import: importReducer,
cloudStorages: cloudStoragesReducer,
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) 2022 CVAT.ai Corporation
#
# 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.location import StorageType, get_location_configuration
from cvat.apps.engine.serializers import DataSerializer, LabeledDataSerializer
from cvat.apps.webhooks.signals import signal_update, signal_create, signal_delete
class TusFile:
_tus_cache_timeout = 3600
@ -321,6 +323,12 @@ class SerializeMixin:
return import_func(request, filename=file_name)
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:
"""
Update fields of a model instance.
@ -329,8 +337,23 @@ class PartialUpdateModelMixin:
"""
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)
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):
kwargs['partial'] = True
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
return project.id if project else None
def get_organization_id(self):
return self.segment.task.organization
def get_bug_tracker(self):
task = self.segment.task
project = task.project
@ -675,6 +678,12 @@ class Issue(models.Model):
updated_date = models.DateTimeField(null=True, blank=True)
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):
issue = models.ForeignKey(Issue, related_name='comments', on_delete=models.CASCADE)
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)
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):
AWS_S3 = 'AWS_S3_BUCKET'
AZURE_CONTAINER = 'AZURE_CONTAINER'

@ -41,6 +41,7 @@ from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied
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.views # pylint: disable=unused-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 cvat.apps.engine.utils import av_scan_paths, process_failed_job, configure_dependent_job
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 . import models, task
@ -270,7 +271,7 @@ class ServerViewSet(viewsets.ViewSet):
})
)
class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin,
PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
):
queryset = models.Project.objects.prefetch_related(Prefetch('label_set',
@ -307,6 +308,7 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
def perform_create(self, serializer):
serializer.save(owner=self.request.user,
organization=self.request.iam_context['organization'])
signal_create.send(self, instance=serializer.instance)
@extend_schema(
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,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin,
PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
):
queryset = Task.objects.prefetch_related(
@ -798,12 +800,25 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
def perform_update(self, serializer):
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()
if instance.project:
instance.project.save()
if updated_instance.project:
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):
instance = serializer.save(owner=self.request.user,
organization=self.request.iam_context['organization'])
@ -811,6 +826,7 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
db_project = instance.project
db_project.save()
assert instance.organization == db_project.organization
signal_create.send(self, instance=serializer.instance)
def perform_destroy(self, instance):
task_dirname = instance.get_dirname()
@ -823,6 +839,7 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
db_project = instance.project
db_project.save()
@extend_schema(summary='Method returns a list of jobs for a specific task',
responses=JobReadSerializer(many=True)) # Duplicate to still get 'list' op. name
@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)
@extend_schema(methods=['PATCH'],
operation_id='jobs_partial_update_annotations_file',
summary="Allows to upload an annotation file chunk. "
@ -1487,6 +1505,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
self._object = self.get_object()
return self.append_tus_chunk(request, file_id)
@extend_schema(summary='Export job as a dataset in a specific format',
parameters=[
OpenApiParameter('format', location=OpenApiParameter.QUERY,
@ -1539,6 +1558,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
return Response(serializer.data)
@extend_schema(summary='Method returns data for a specific job',
parameters=[
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,
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',
responses={
'200': DataMetaReadSerializer,
@ -1685,7 +1706,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
})
)
class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin,
PartialUpdateModelMixin
):
queryset = Issue.objects.all().order_by('-id')
@ -1717,6 +1738,7 @@ class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
def perform_create(self, serializer):
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',
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,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin,
mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin,
PartialUpdateModelMixin
):
queryset = Comment.objects.all().order_by('-id')
@ -1792,6 +1814,7 @@ class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
signal_create.send(self, instance=serializer.instance)
@extend_schema(tags=['users'])
@extend_schema_view(

@ -1,4 +1,5 @@
# Copyright (C) 2022 Intel Corporation
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
@ -12,6 +13,7 @@ from django.conf import settings
from django.db.models import Q
from rest_framework.permissions import BasePermission
from cvat.apps.webhooks.models import Webhook
from cvat.apps.organizations.models import Membership, Organization
from cvat.apps.engine.models import Project, Task, Job, Issue
@ -763,6 +765,99 @@ class TaskPermission(OpenPolicyAgentPermission):
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):
@classmethod
def create(cls, request, view, obj):
@ -1029,6 +1124,7 @@ class IssuePermission(OpenPolicyAgentPermission):
return data
class PolicyEnforcer(BasePermission):
# pylint: disable=no-self-use
def check_permission(self, request, view, obj):
@ -1071,3 +1167,4 @@ class IsMemberInOrganization(BasePermission):
return membership is not None
return True

@ -35,6 +35,7 @@ UPDATE_OWNER := "update:owner"
EXPORT_ANNOTATIONS := "export:annotations"
EXPORT_DATASET := "export:dataset"
CREATE_IN_PROJECT := "create@project"
CREATE_IN_ORGANIZATION := "create@organization"
UPDATE_PROJECT := "update:project"
VIEW_ANNOTATIONS := "view: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 django.conf import settings
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)
membership = models.OneToOneField(Membership, on_delete=models.CASCADE)
def get_organization_id(self):
return self.membership.organization_id
def send(self):
if not strtobool(settings.ORG_INVITATION_CONFIRM):
self.accept(self.created_date)

@ -1,4 +1,5 @@
# Copyright (C) 2021-2022 Intel Corporation
# Copyright (C) 2022 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
@ -7,6 +8,8 @@ from rest_framework.permissions import SAFE_METHODS
from django.utils.crypto import get_random_string
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 (
InvitationPermission, MembershipPermission, OrganizationPermission)
@ -25,7 +28,7 @@ from .serializers import (
'200': OrganizationReadSerializer,
}),
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={
'200': OrganizationReadSerializer(many=True),
}),
@ -50,7 +53,13 @@ from .serializers import (
'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()
search_fields = ('name', 'owner')
filter_fields = list(search_fields) + ['id', 'slug']
@ -110,8 +119,8 @@ class OrganizationViewSet(viewsets.ModelViewSet):
'204': OpenApiResponse(description='The membership has been deleted'),
})
)
class MembershipViewSet(mixins.RetrieveModelMixin, mixins.DestroyModelMixin,
mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
class MembershipViewSet(mixins.RetrieveModelMixin, DestroyModelMixin,
mixins.ListModelMixin, PartialUpdateModelMixin, viewsets.GenericViewSet):
queryset = Membership.objects.all()
ordering = '-id'
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'),
})
)
class InvitationViewSet(viewsets.ModelViewSet):
class InvitationViewSet(viewsets.GenericViewSet,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
DestroyModelMixin,
):
queryset = Invitation.objects.all()
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
iam_organization_field = 'membership__organization'
@ -194,6 +209,7 @@ class InvitationViewSet(viewsets.ModelViewSet):
'organization': self.request.iam_context['organization']
}
serializer.save(**extra_kwargs)
signal_create.send(self, instance=serializer.instance)
def perform_update(self, serializer):
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.lambda_manager',
'cvat.apps.opencv',
'cvat.apps.webhooks',
]
SITE_ID = 1
@ -277,6 +278,12 @@ RQ_QUEUES = {
'PORT': 6379,
'DB': 0,
'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'):
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'):
urlpatterns.append(path('profiler/', include('silk.urls')))

@ -117,6 +117,27 @@ services:
networks:
- 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:
container_name: cvat_ui
image: cvat/ui:${CVAT_VERSION:-dev}

@ -42,7 +42,6 @@
"eslint-config-airbnb-base": "14.2.1",
"eslint-config-airbnb-typescript": "^12.0.0",
"eslint-plugin-cypress": "^2.11.2",
"eslint-plugin-header": "^3.1.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^26.5.3",
"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"
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]
command=%(ENV_HOME)s/wait-for-it.sh %(ENV_CVAT_REDIS_HOST)s:6379 -t 0 -- bash -ic \
"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',
'lint-staged.config.js',
],
plugins: ['security', 'no-unsanitized', 'eslint-plugin-header', 'import'],
plugins: ['security', 'no-unsanitized', 'import'],
extends: [
'eslint:recommended', 'plugin:security/recommended', 'plugin:no-unsanitized/DOM', 'plugin:cypress/recommended',
'airbnb-base', 'plugin:import/errors', 'plugin:import/warnings',

@ -13,6 +13,7 @@
"testFiles": [
"auth_page.js",
"skeletons_pipeline.js",
"webhooks.js",
"actions_tasks/**/*.js",
"actions_tasks2/**/*.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_organizations');
require('./commands_cloud_storages');
require('./commands_webhooks');
require('@cypress/code-coverage/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,14 +20,9 @@ from .utils import export_dataset
@pytest.mark.usefixtures('dontchangedb')
class TestGetProjects:
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:
if is_project_staff(user['id'], p['id']):
return p['id']
else:
for p in projects:
if not is_project_staff(user['id'], p['id']):
return p['id']
for p in projects:
if is_project_staff(user['id'], p['id']) == is_project_staff_flag:
return p['id']
def _test_response_200(self, username, project_id, **kwargs):
with make_api_client(username) as api_client:
@ -78,48 +73,43 @@ class TestGetProjects:
)
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'))
def test_if_supervisor_or_worker_cannot_see_project(self, projects, is_project_staff,
find_users, role):
non_admins = find_users(role=role, exclude_privilege='admin')
assert non_admins is not None
project_id = self._find_project_by_user_org(non_admins[0], projects, False, is_project_staff)
assert project_id is not None
user, pid = next((
(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'])
))
self._test_response_403(non_admins[0]['username'], project_id)
self._test_response_403(user['username'], pid)
# 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'))
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')
assert non_admins is not None
user, pid = next((
(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)
assert project_id is not None
self._test_response_200(user['username'], pid, org_id=user['org'])
self._test_response_200(non_admins[0]['username'], project_id, org_id=non_admins[0]['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'))
def test_if_org_member_supervisor_or_worker_can_see_project(self, projects,
find_users, is_project_staff, role):
non_admins = find_users(role=role, exclude_privilege='admin')
assert len(non_admins)
for u in non_admins:
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
user, pid = next((
(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'])
))
self._test_response_200(user_in_project['username'], project_id, org_id=user_in_project['org'])
self._test_response_200(user['username'], pid, org_id=user['org'])
class TestGetProjectBackup:
def _test_can_get_project_backup(self, username, pid, **kwargs):
@ -159,7 +149,9 @@ class TestGetProjectBackup:
user, project = next(
(user, project)
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'])
@ -171,7 +163,9 @@ class TestGetProjectBackup:
user, project = next(
(user, project)
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'])
@ -183,7 +177,9 @@ class TestGetProjectBackup:
user, project = next(
(user, project)
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'])
@ -195,7 +191,9 @@ class TestGetProjectBackup:
user, project = next(
(user, project)
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'])
@ -207,7 +205,9 @@ class TestGetProjectBackup:
user, project = next(
(user, project)
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'])
@ -219,7 +219,9 @@ class TestGetProjectBackup:
user, project = next(
(user, project)
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'])
@ -248,7 +250,7 @@ class TestPostProjects:
self._test_create_project_403(username, spec)
@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)
assert len(privileged_users)
@ -427,7 +429,7 @@ class TestPatchProjectLabel:
assert len(response.json()['labels']) == len(project['labels']) - 1
def test_admin_can_delete_skeleton_label(self, projects):
project = deepcopy(list(projects)[0])
project = deepcopy(projects[5])
labels = project['labels'][0]
labels.update({'deleted': True})
response = patch_method('admin1', f'/projects/{project["id"]}', {'labels': [labels]})
@ -456,7 +458,9 @@ class TestPatchProjectLabel:
user, project = next(
(user, project)
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'}
@ -471,7 +475,9 @@ class TestPatchProjectLabel:
user, project = next(
(user, project)
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'}
@ -485,7 +491,9 @@ class TestPatchProjectLabel:
user, project = next(
(user, project)
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'}
@ -499,7 +507,9 @@ class TestPatchProjectLabel:
user, project = next(
(user, project)
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'}
@ -514,7 +524,9 @@ class TestPatchProjectLabel:
user, project = next(
(user, project)
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'}

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,
"fields": {
"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,
"username": "admin1",
"first_name": "Admin",
@ -58,7 +58,7 @@
"pk": 2,
"fields": {
"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,
"username": "user1",
"first_name": "User",
@ -80,7 +80,7 @@
"pk": 3,
"fields": {
"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,
"username": "user2",
"first_name": "User",
@ -146,7 +146,7 @@
"pk": 6,
"fields": {
"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,
"username": "worker1",
"first_name": "Worker",
@ -234,7 +234,7 @@
"pk": 10,
"fields": {
"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,
"username": "business1",
"first_name": "Business",
@ -463,6 +463,14 @@
"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",
"pk": "9rh2r15lb3xra3kdqjtll5n4zw7ebw95",
@ -543,6 +551,14 @@
"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",
"pk": "oy4oy702g9qr34fjne8jnxoxvqaiaq26",
@ -577,30 +593,28 @@
},
{
"model": "sessions.session",
"pk": "wf6d6vzf4u74l08o0qgbqehei21hibea",
"pk": "vph7z81qem6c9705bnj7xxvzmzod24ck",
"fields": {
"session_data": ".eJxVjDEOwjAMRe-SGUUkpHZgZO8ZIttxSAG1UtNOiLtDpQ6w_vfef5lE61LT2nROQzYX48zhd2OSh44byHcab5OVaVzmge2m2J02209Zn9fd_Tuo1Oq3DrGwD040Ro_-nJmJgkgsqAAIioCi0KGKMhU4Mgip6wjRF6JyMu8PBAI5Mw:1nIXJc:oovNJRods5cbviWOWush4H3jDdP8XklEignva_EnQ8Q",
"expire_date": "2022-02-25T14:54:28.092Z"
"session_data": ".eJxVjEEOgjAQRe_StWlmahlal-45A5npFEFNSSisjHdXEha6_e-9_zI9b-vYbzUv_aTmYhDM6XcUTo9cdqJ3LrfZprmsyyR2V-xBq-1mzc_r4f4djFzHbx2G5oxKA4foXCRS9QlEghKDpwBNExWyEw-Z2owOJSTH2IK24smjeX8A-A43ag:1oVRr1:YjNerWaOn53u4nuATnD-oXeI5uqdL6lAyKX4jCG0b94",
"expire_date": "2022-09-20T06:14:35.567Z"
}
},
{
"model": "authtoken.token",
"pk": "2d5bca87ec38cba82ad1da525431ec3a224385b6",
"model": "sessions.session",
"pk": "wf6d6vzf4u74l08o0qgbqehei21hibea",
"fields": {
"user": [
"admin1"
],
"created": "2022-06-08T08:32:30.149Z"
"session_data": ".eJxVjDEOwjAMRe-SGUUkpHZgZO8ZIttxSAG1UtNOiLtDpQ6w_vfef5lE61LT2nROQzYX48zhd2OSh44byHcab5OVaVzmge2m2J02209Zn9fd_Tuo1Oq3DrGwD040Ro_-nJmJgkgsqAAIioCi0KGKMhU4Mgip6wjRF6JyMu8PBAI5Mw:1nIXJc:oovNJRods5cbviWOWush4H3jDdP8XklEignva_EnQ8Q",
"expire_date": "2022-02-25T14:54:28.092Z"
}
},
{
"model": "authtoken.token",
"pk": "3952f1aea900fc3daa269473a71c41fac08858b5",
"pk": "4f057576712c65d30847e77456aea605a9df5965",
"fields": {
"user": [
"business1"
"admin1"
],
"created": "2022-03-05T10:31:48.838Z"
"created": "2022-09-28T12:20:48.631Z"
}
},
{
@ -811,6 +825,19 @@
"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",
"pk": "5FjIXya6fTGvlRpauFvi2QN1wDOqo1V9REB5rJinDR8FZO9gr0qmtWpghsCte8Y1",
@ -910,6 +937,17 @@
"membership": 9
}
},
{
"model": "organizations.invitation",
"pk": "hIH9RB3QqZLFwdUDmufaSPc2H8uS5cNjcG6pk8gfAIQ4jg6nJZZWDIQHMN1gFMk9",
"fields": {
"created_date": "2022-09-28T13:11:37.839Z",
"owner": [
"admin1"
],
"membership": 13
}
},
{
"model": "organizations.invitation",
"pk": "mFpVV2Yh39uUdU8IpigSxvuPegqi8sjxFi6P9Jdy6fBE8Ky9Juzi1KjeGDQsizSS",
@ -2196,7 +2234,7 @@
"assignee": null,
"bug_tracker": "",
"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",
"organization": 2,
"source_storage": null,
@ -2214,13 +2252,53 @@
"assignee": null,
"bug_tracker": "",
"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",
"organization": 2,
"source_storage": 5,
"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",
"pk": 2,
@ -3794,6 +3872,30 @@
"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",
"pk": 1,
@ -5647,6 +5749,167 @@
"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",
"pk": 1,

@ -1,8 +1,28 @@
{
"count": 10,
"count": 11,
"next": null,
"previous": null,
"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",
"key": "Fi3WRUhFxTWpMiVpdwNR2CGyhgcIXSCUYgPCugPq72QUOgHz9NSMOGiKS3PfJ7Ql",

@ -1,8 +1,23 @@
{
"count": 12,
"count": 13,
"next": null,
"previous": null,
"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,
"invitation": "Fi3WRUhFxTWpMiVpdwNR2CGyhgcIXSCUYgPCugPq72QUOgHz9NSMOGiKS3PfJ7Ql",

@ -1,8 +1,104 @@
{
"count": 5,
"count": 7,
"next": null,
"previous": null,
"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,
"bug_tracker": "",
@ -212,7 +308,7 @@
"tasks": [
14
],
"updated_date": "2022-09-23T11:57:02.088000Z",
"updated_date": "2022-09-28T12:26:49.493000Z",
"url": "http://localhost:8080/api/projects/5"
},
{
@ -257,7 +353,7 @@
"tasks": [
13
],
"updated_date": "2022-06-08T08:33:20.759000Z",
"updated_date": "2022-09-28T12:26:54.279000Z",
"url": "http://localhost:8080/api/projects/4"
},
{

@ -166,7 +166,7 @@
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2022-03-05T10:31:48.850000Z",
"last_login": "2022-09-28T12:17:51.373000Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/10",
"username": "business1"
@ -230,7 +230,7 @@
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2021-12-14T19:11:21.048000Z",
"last_login": "2022-09-06T07:57:19.879000Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/6",
"username": "worker1"
@ -278,7 +278,7 @@
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2022-03-28T13:05:05.561000Z",
"last_login": "2022-09-28T12:19:33.698000Z",
"last_name": "Second",
"url": "http://localhost:8080/api/users/3",
"username": "user2"
@ -294,7 +294,7 @@
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2022-03-17T07:22:09.327000Z",
"last_login": "2022-09-28T12:15:35.182000Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/2",
"username": "user1"
@ -310,7 +310,7 @@
"is_active": true,
"is_staff": true,
"is_superuser": true,
"last_login": "2022-09-22T14:21:28.429000Z",
"last_login": "2022-09-28T12:20:48.633000Z",
"last_name": "First",
"url": "http://localhost:8080/api/users/1",
"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:
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')
def users_by_name(users):
return {user['username']: user for user in users}

@ -27,7 +27,11 @@ CONTAINER_NAME_FILES = [
DC_FILES = [
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
@ -208,7 +212,9 @@ def start_services(rebuild=False):
)
_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,
capture_output=False,
)
@ -251,7 +257,9 @@ def services(request):
if stop:
_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,
)
pytest.exit("All testing containers are stopped", returncode=0)

@ -9,7 +9,7 @@ import json
if __name__ == '__main__':
annotations = {}
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')
with open(osp.join(ASSETS_DIR, f'{obj}s.json'), 'w') as f:
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:
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:
version "2.26.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b"

Loading…
Cancel
Save