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
parent
8b719e4959
commit
bae7564968
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>;
|
||||
@ -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);
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
@ -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
@ -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)
|
||||
@ -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"
|
||||
@ -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');
|
||||
});
|
||||
@ -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
|
||||
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']"],
|
||||
)
|
||||
== {}
|
||||
)
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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()
|
||||
Loading…
Reference in New Issue