bkflow-django-webhook 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bkflow_django_webhook-1.0.0/PKG-INFO +133 -0
- bkflow_django_webhook-1.0.0/README.md +118 -0
- bkflow_django_webhook-1.0.0/pyproject.toml +36 -0
- bkflow_django_webhook-1.0.0/setup.py +37 -0
- bkflow_django_webhook-1.0.0/webhook/__init__.py +3 -0
- bkflow_django_webhook-1.0.0/webhook/admin.py +37 -0
- bkflow_django_webhook-1.0.0/webhook/api.py +67 -0
- bkflow_django_webhook-1.0.0/webhook/apps.py +16 -0
- bkflow_django_webhook-1.0.0/webhook/base_models.py +38 -0
- bkflow_django_webhook-1.0.0/webhook/config.py +52 -0
- bkflow_django_webhook-1.0.0/webhook/contrib/__init__.py +1 -0
- bkflow_django_webhook-1.0.0/webhook/contrib/drf/__init__.py +1 -0
- bkflow_django_webhook-1.0.0/webhook/contrib/drf/serializers.py +32 -0
- bkflow_django_webhook-1.0.0/webhook/handlers.py +103 -0
- bkflow_django_webhook-1.0.0/webhook/management/__init__.py +1 -0
- bkflow_django_webhook-1.0.0/webhook/management/commands/__init__.py +1 -0
- bkflow_django_webhook-1.0.0/webhook/management/commands/sync_webhook_events.py +35 -0
- bkflow_django_webhook-1.0.0/webhook/migrations/0001_initial.py +80 -0
- bkflow_django_webhook-1.0.0/webhook/migrations/0002_auto_20240202_1148.py +25 -0
- bkflow_django_webhook-1.0.0/webhook/migrations/__init__.py +0 -0
- bkflow_django_webhook-1.0.0/webhook/models.py +136 -0
- bkflow_django_webhook-1.0.0/webhook/requester/__init__.py +3 -0
- bkflow_django_webhook-1.0.0/webhook/requester/base.py +80 -0
- bkflow_django_webhook-1.0.0/webhook/signals/__init__.py +10 -0
- bkflow_django_webhook-1.0.0/webhook/signals/handlers.py +15 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: bkflow-django-webhook
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A Django app to make it easy for integrating webhook into service.
|
|
5
|
+
Author-email: normal-wls <weishi.swee@qq.com>
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: Django >2, <4
|
|
8
|
+
Requires-Dist: pyyaml >5, <7
|
|
9
|
+
Requires-Dist: pydantic <3
|
|
10
|
+
Requires-Dist: pytest >=7.0.1,<8 ; extra == "test"
|
|
11
|
+
Requires-Dist: pytest-django>=4.5.2,<5.0 ; extra == "test"
|
|
12
|
+
Requires-Dist: djangorestframework >3, <4 ; extra == "test"
|
|
13
|
+
Project-URL: Home, https://github.com/TencentBlueKing/bkflow-django-webhook
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
|
|
16
|
+
# bkflow-django-webhook
|
|
17
|
+
|
|
18
|
+
## 简介
|
|
19
|
+
bkflow-django-webhook 是一款支持系统快速集成 webhook 功能的 Django app。
|
|
20
|
+
|
|
21
|
+
webhook 是一种在特定事件发生时,通过 HTTP 请求将数据发送到指定的 URL 的实时通信的机制。
|
|
22
|
+
|
|
23
|
+
webhook 的使用场景非常广泛。例如,当用户在某个网站上进行了特定操作(如提交表单、创建账户或进行支付)时,网站可以使用 webhook 将相关数据发送给其他应用程序或服务。这样,接收方应用程序就能够实时获取到这些数据,并根据需要进行处理。
|
|
24
|
+
|
|
25
|
+
集成 bkflow-django-webhook,可以让 Django 应用快速获得 webhook 所需要的能力,帮助应用实现基于事件的服务自动化调用。
|
|
26
|
+
|
|
27
|
+
## 相关概念
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
上图描述了从事件触发到最终请求的过程,涉及以下几个概念:
|
|
31
|
+
1. 请求配置 (Webhook) : 记录发送请求所需要的 endpoint、token 等信息,根据该配置发送 HTTP POST 请求完成对外部服务的调用。
|
|
32
|
+
2. 事件 (Event) : 系统触发请求的类型,需要提前定义。
|
|
33
|
+
3. 领域 (Scope) : `事件`的触发往往是因为资源的变更,而资源会有`领域`的划分,不同`领域`的资源往往需要发送不同的请求。(上图的 template 是资源,template 属于不同的业务,业务就是 Scope,不同业务下的资源事件可能会触发不同的 webhook 请求。比如 biz_1 业务下的 template 变更时,会通过 webhook 1 请求 service 1;而 biz_2 业务下的 template 变更时,会通过 webhook 2 请求 service 2。)
|
|
34
|
+
4. 订阅 (Subscription) : 记录不同`事件`、`领域`和`请求配置`订阅关系的配置,用于在`事件`触发时筛选出对应的`请求配置`进行请求调用。
|
|
35
|
+
|
|
36
|
+
在 bkflow-django-webhook 的设计中,资源 (图中 template) 的变更触发了事件 (Event),根据事件、资源所属领域 (Scope, 图中 biz) 和订阅 (Subscription) 的记录,筛选出命中的请求配置 (Webhook),最终对对应的服务 (service) 发送请求。
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
## 快速上手
|
|
40
|
+
|
|
41
|
+
下面以上图为例,说明如何快速上手。
|
|
42
|
+
|
|
43
|
+
#### 安装
|
|
44
|
+
|
|
45
|
+
```shell
|
|
46
|
+
pip install bkflow-django-webhook
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
#### 添加到 Django 项目中
|
|
50
|
+
|
|
51
|
+
将 `webhook` 添加到 Django 项目中
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
INSTALLED_APPS = [
|
|
55
|
+
...
|
|
56
|
+
'webhook',
|
|
57
|
+
]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
建表
|
|
61
|
+
|
|
62
|
+
```shell
|
|
63
|
+
python manage.py migrate webhook
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### 事件定义
|
|
67
|
+
|
|
68
|
+
创建定义资源文件,如 `webhook_events.yaml`
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
version: 1
|
|
72
|
+
events:
|
|
73
|
+
- code: template_update
|
|
74
|
+
name: 模板更新
|
|
75
|
+
- code: template_create
|
|
76
|
+
name: 模板创建
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
目前支持对多个事件进行定义,其中 `code` 定义事件唯一键,`name` 定义事件名称。
|
|
80
|
+
|
|
81
|
+
#### 事件同步
|
|
82
|
+
|
|
83
|
+
通过 `sync_webhook_events` 命令进行事件同步(可以在每次应用启动时调用进行同步)
|
|
84
|
+
|
|
85
|
+
```shell
|
|
86
|
+
python manage.py sync_webhook_events . webhook_events.yaml
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`sync_webhook_events` 命令包含两个位置参数:
|
|
90
|
+
- base_path: 资源文件所在的目录地址
|
|
91
|
+
- filename: 资源文件名
|
|
92
|
+
|
|
93
|
+
#### 资源注册
|
|
94
|
+
|
|
95
|
+
通过 bkflow-django-webhook 的 api 注册 请求配置 和 订阅关系。
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from webhook.api import apply_scope_subscriptions, apply_scope_webhooks
|
|
99
|
+
|
|
100
|
+
webhook_configs = [
|
|
101
|
+
{"code": "webhook1", "name": "webhook1", "endpoint": "https://xxx.com"}, # endpoint 是接收请求的服务地址
|
|
102
|
+
{"code": "webhook2", "name": "webhook2", "endpoint": "https://xxx.com"}
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
subscription_configs = {"webhook1": ["*"], "webhook2": ["template_update"]} # "*" 表示订阅所有事件
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
apply_scope_webhooks(scope_type="biz", scope_code="biz1", webhooks=webhook_configs)
|
|
109
|
+
apply_scope_subscriptions(scope_type="biz", scope_code="biz1", subscription_configs=subscription_configs)
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### 发起请求
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from webhook.signals import event_broadcast_signal
|
|
117
|
+
|
|
118
|
+
# 在对应的业务逻辑中触发调用
|
|
119
|
+
event_broadcast_signal.send(
|
|
120
|
+
sender="template_update",
|
|
121
|
+
scopes=[("biz", "biz1")],
|
|
122
|
+
extra_info={"template_id": "template1"},
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
根据触发的事件(sender)、领域(scopes,可支持多个)过滤出对应的请求配置,并同步发送请求(extra_info将作为请求参数)。
|
|
127
|
+
|
|
128
|
+
请求完成后,可在项目的 django admin 页面 Webhook/History 中查看请求历史。
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
## 资料
|
|
133
|
+
[Release](release.md)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# bkflow-django-webhook
|
|
2
|
+
|
|
3
|
+
## 简介
|
|
4
|
+
bkflow-django-webhook 是一款支持系统快速集成 webhook 功能的 Django app。
|
|
5
|
+
|
|
6
|
+
webhook 是一种在特定事件发生时,通过 HTTP 请求将数据发送到指定的 URL 的实时通信的机制。
|
|
7
|
+
|
|
8
|
+
webhook 的使用场景非常广泛。例如,当用户在某个网站上进行了特定操作(如提交表单、创建账户或进行支付)时,网站可以使用 webhook 将相关数据发送给其他应用程序或服务。这样,接收方应用程序就能够实时获取到这些数据,并根据需要进行处理。
|
|
9
|
+
|
|
10
|
+
集成 bkflow-django-webhook,可以让 Django 应用快速获得 webhook 所需要的能力,帮助应用实现基于事件的服务自动化调用。
|
|
11
|
+
|
|
12
|
+
## 相关概念
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
上图描述了从事件触发到最终请求的过程,涉及以下几个概念:
|
|
16
|
+
1. 请求配置 (Webhook) : 记录发送请求所需要的 endpoint、token 等信息,根据该配置发送 HTTP POST 请求完成对外部服务的调用。
|
|
17
|
+
2. 事件 (Event) : 系统触发请求的类型,需要提前定义。
|
|
18
|
+
3. 领域 (Scope) : `事件`的触发往往是因为资源的变更,而资源会有`领域`的划分,不同`领域`的资源往往需要发送不同的请求。(上图的 template 是资源,template 属于不同的业务,业务就是 Scope,不同业务下的资源事件可能会触发不同的 webhook 请求。比如 biz_1 业务下的 template 变更时,会通过 webhook 1 请求 service 1;而 biz_2 业务下的 template 变更时,会通过 webhook 2 请求 service 2。)
|
|
19
|
+
4. 订阅 (Subscription) : 记录不同`事件`、`领域`和`请求配置`订阅关系的配置,用于在`事件`触发时筛选出对应的`请求配置`进行请求调用。
|
|
20
|
+
|
|
21
|
+
在 bkflow-django-webhook 的设计中,资源 (图中 template) 的变更触发了事件 (Event),根据事件、资源所属领域 (Scope, 图中 biz) 和订阅 (Subscription) 的记录,筛选出命中的请求配置 (Webhook),最终对对应的服务 (service) 发送请求。
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## 快速上手
|
|
25
|
+
|
|
26
|
+
下面以上图为例,说明如何快速上手。
|
|
27
|
+
|
|
28
|
+
#### 安装
|
|
29
|
+
|
|
30
|
+
```shell
|
|
31
|
+
pip install bkflow-django-webhook
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
#### 添加到 Django 项目中
|
|
35
|
+
|
|
36
|
+
将 `webhook` 添加到 Django 项目中
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
INSTALLED_APPS = [
|
|
40
|
+
...
|
|
41
|
+
'webhook',
|
|
42
|
+
]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
建表
|
|
46
|
+
|
|
47
|
+
```shell
|
|
48
|
+
python manage.py migrate webhook
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
#### 事件定义
|
|
52
|
+
|
|
53
|
+
创建定义资源文件,如 `webhook_events.yaml`
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
version: 1
|
|
57
|
+
events:
|
|
58
|
+
- code: template_update
|
|
59
|
+
name: 模板更新
|
|
60
|
+
- code: template_create
|
|
61
|
+
name: 模板创建
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
目前支持对多个事件进行定义,其中 `code` 定义事件唯一键,`name` 定义事件名称。
|
|
65
|
+
|
|
66
|
+
#### 事件同步
|
|
67
|
+
|
|
68
|
+
通过 `sync_webhook_events` 命令进行事件同步(可以在每次应用启动时调用进行同步)
|
|
69
|
+
|
|
70
|
+
```shell
|
|
71
|
+
python manage.py sync_webhook_events . webhook_events.yaml
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`sync_webhook_events` 命令包含两个位置参数:
|
|
75
|
+
- base_path: 资源文件所在的目录地址
|
|
76
|
+
- filename: 资源文件名
|
|
77
|
+
|
|
78
|
+
#### 资源注册
|
|
79
|
+
|
|
80
|
+
通过 bkflow-django-webhook 的 api 注册 请求配置 和 订阅关系。
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from webhook.api import apply_scope_subscriptions, apply_scope_webhooks
|
|
84
|
+
|
|
85
|
+
webhook_configs = [
|
|
86
|
+
{"code": "webhook1", "name": "webhook1", "endpoint": "https://xxx.com"}, # endpoint 是接收请求的服务地址
|
|
87
|
+
{"code": "webhook2", "name": "webhook2", "endpoint": "https://xxx.com"}
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
subscription_configs = {"webhook1": ["*"], "webhook2": ["template_update"]} # "*" 表示订阅所有事件
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
apply_scope_webhooks(scope_type="biz", scope_code="biz1", webhooks=webhook_configs)
|
|
94
|
+
apply_scope_subscriptions(scope_type="biz", scope_code="biz1", subscription_configs=subscription_configs)
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### 发起请求
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from webhook.signals import event_broadcast_signal
|
|
102
|
+
|
|
103
|
+
# 在对应的业务逻辑中触发调用
|
|
104
|
+
event_broadcast_signal.send(
|
|
105
|
+
sender="template_update",
|
|
106
|
+
scopes=[("biz", "biz1")],
|
|
107
|
+
extra_info={"template_id": "template1"},
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
根据触发的事件(sender)、领域(scopes,可支持多个)过滤出对应的请求配置,并同步发送请求(extra_info将作为请求参数)。
|
|
112
|
+
|
|
113
|
+
请求完成后,可在项目的 django admin 页面 Webhook/History 中查看请求历史。
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
## 资料
|
|
118
|
+
[Release](release.md)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core >=3.2,<4"]
|
|
3
|
+
build-backend = "flit_core.buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bkflow-django-webhook"
|
|
7
|
+
description = "A Django app to make it easy for integrating webhook into service."
|
|
8
|
+
authors = [{name = "normal-wls", email = "weishi.swee@qq.com"}]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"Django >2, <4",
|
|
13
|
+
"pyyaml >5, <7",
|
|
14
|
+
"pydantic <3",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Home = "https://github.com/TencentBlueKing/bkflow-django-webhook"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = [
|
|
22
|
+
"pytest >=7.0.1,<8",
|
|
23
|
+
"pytest-django>=4.5.2,<5.0",
|
|
24
|
+
"djangorestframework >3, <4",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.flit.module]
|
|
28
|
+
name = "webhook"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
[tool.black]
|
|
32
|
+
line-length = 120
|
|
33
|
+
fast = true
|
|
34
|
+
|
|
35
|
+
[tool.isort]
|
|
36
|
+
line_length = 120
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# setup.py generated by flit for tools that don't yet use PEP 517
|
|
3
|
+
|
|
4
|
+
from distutils.core import setup
|
|
5
|
+
|
|
6
|
+
packages = \
|
|
7
|
+
['webhook',
|
|
8
|
+
'webhook.contrib',
|
|
9
|
+
'webhook.contrib.drf',
|
|
10
|
+
'webhook.management',
|
|
11
|
+
'webhook.management.commands',
|
|
12
|
+
'webhook.migrations',
|
|
13
|
+
'webhook.requester',
|
|
14
|
+
'webhook.signals']
|
|
15
|
+
|
|
16
|
+
package_data = \
|
|
17
|
+
{'': ['*']}
|
|
18
|
+
|
|
19
|
+
install_requires = \
|
|
20
|
+
['Django >2, <4', 'pyyaml >5, <7', 'pydantic <3']
|
|
21
|
+
|
|
22
|
+
extras_require = \
|
|
23
|
+
{'test': ['pytest >=7.0.1,<8',
|
|
24
|
+
'pytest-django>=4.5.2,<5.0',
|
|
25
|
+
'djangorestframework >3, <4']}
|
|
26
|
+
|
|
27
|
+
setup(name='bkflow-django-webhook',
|
|
28
|
+
version='1.0.0',
|
|
29
|
+
description='A Django app to make it easy for integrating webhook into service.',
|
|
30
|
+
author=None,
|
|
31
|
+
author_email='normal-wls <weishi.swee@qq.com>',
|
|
32
|
+
url=None,
|
|
33
|
+
packages=packages,
|
|
34
|
+
package_data=package_data,
|
|
35
|
+
install_requires=install_requires,
|
|
36
|
+
extras_require=extras_require,
|
|
37
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from django.contrib import admin
|
|
3
|
+
|
|
4
|
+
from webhook import models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@admin.register(models.Event)
|
|
8
|
+
class EventAdmin(admin.ModelAdmin):
|
|
9
|
+
list_display = [field.name for field in models.Event._meta.fields]
|
|
10
|
+
search_fields = ["code", "name"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@admin.register(models.Webhook)
|
|
14
|
+
class WebhookAdmin(admin.ModelAdmin):
|
|
15
|
+
list_display = [field.name for field in models.Webhook._meta.fields]
|
|
16
|
+
search_fields = ["code", "name", "scope_code"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@admin.register(models.History)
|
|
20
|
+
class HistoryAdmin(admin.ModelAdmin):
|
|
21
|
+
list_display = [field.name for field in models.History._meta.fields]
|
|
22
|
+
search_fields = ["delivery_id", "webhook_code", "event_code", "scope_code"]
|
|
23
|
+
list_filter = ["success", "status_code"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@admin.register(models.Subscription)
|
|
27
|
+
class SubscriptionAdmin(admin.ModelAdmin):
|
|
28
|
+
list_display = [field.name for field in models.Subscription._meta.fields]
|
|
29
|
+
search_fields = ["webhook_code", "event_code", "scope_code"]
|
|
30
|
+
list_filter = ["event_code"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@admin.register(models.Scope)
|
|
34
|
+
class ScopeAdmin(admin.ModelAdmin):
|
|
35
|
+
list_display = [field.name for field in models.Scope._meta.fields]
|
|
36
|
+
search_fields = ["code"]
|
|
37
|
+
list_filter = ["type"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List, Tuple, Union, Dict
|
|
4
|
+
|
|
5
|
+
from webhook.base_models import Event, Scope, Webhook
|
|
6
|
+
from webhook.handlers import EventHandler
|
|
7
|
+
from webhook.models import Event as EventModel
|
|
8
|
+
from webhook.models import Subscription
|
|
9
|
+
from webhook.models import Webhook as WebhookModel
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_scope_webhooks(scope_type: str, scope_code: str) -> List[Webhook]:
|
|
15
|
+
"""
|
|
16
|
+
get webhooks of scope
|
|
17
|
+
"""
|
|
18
|
+
scope = Scope(scope_type=scope_type, scope_code=scope_code)
|
|
19
|
+
return [
|
|
20
|
+
Webhook.from_orm(webhook)
|
|
21
|
+
for webhook in WebhookModel.objects.filter(scope_type=scope.type, scope_code=scope.code)
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def apply_scope_webhooks(scope_type: str, scope_code: str, webhooks: List[Dict]) -> None:
|
|
26
|
+
"""
|
|
27
|
+
update or create webhooks by given list and delete others
|
|
28
|
+
"""
|
|
29
|
+
scope = Scope(type=scope_type, code=scope_code)
|
|
30
|
+
webhooks = [Webhook(**webhook, scope_type=scope_type, scope_code=scope_code) for webhook in webhooks]
|
|
31
|
+
WebhookModel.objects.apply_scope_webhooks(scope, webhooks)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def apply_scope_subscriptions(scope_type: str, scope_code: str, subscription_configs: Dict) -> None:
|
|
35
|
+
scope = Scope(type=scope_type, code=scope_code)
|
|
36
|
+
Subscription.objects.apply_scope_subscriptions(scope, subscription_configs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def event_broadcast(event: Union[Event, str], scopes: List[Union[Scope, Tuple[str, str]]], *args, **kwargs):
|
|
40
|
+
"""
|
|
41
|
+
broadcast event to make subscription webhooks send requests
|
|
42
|
+
"""
|
|
43
|
+
logger.info(f"[event broadcasting...] event: {event}, scopes: {scopes}, args: {args}, kwargs: {kwargs}")
|
|
44
|
+
if isinstance(event, str):
|
|
45
|
+
event_instance = EventModel.objects.filter(code=event).first()
|
|
46
|
+
if not event_instance:
|
|
47
|
+
logger.error(f"event {event} not found")
|
|
48
|
+
return
|
|
49
|
+
event = Event.from_orm(event_instance)
|
|
50
|
+
|
|
51
|
+
extra_info = kwargs.get("extra_info", {})
|
|
52
|
+
if event.info:
|
|
53
|
+
event.info.update(extra_info)
|
|
54
|
+
else:
|
|
55
|
+
event.info = extra_info
|
|
56
|
+
if not isinstance(event, Event):
|
|
57
|
+
logger.error(f"event {event} is not a Event instance")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if not all([isinstance(scope, (Scope, tuple)) for scope in scopes]):
|
|
61
|
+
logger.error(f"scopes {scopes} is not a Scope or tuple instance")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
scopes = [Scope(type=scope[0], code=scope[1]) if isinstance(scope, tuple) else scope for scope in scopes]
|
|
65
|
+
|
|
66
|
+
event_handler = EventHandler(event)
|
|
67
|
+
event_handler.handle(scopes=scopes)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WebhookConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "webhook"
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
from .signals import ( # noqa
|
|
10
|
+
event_broadcast_signal,
|
|
11
|
+
post_event_broadcast_signal,
|
|
12
|
+
post_send_request_signal,
|
|
13
|
+
pre_event_broadcast_signal,
|
|
14
|
+
pre_send_request_signal,
|
|
15
|
+
)
|
|
16
|
+
from .signals.handlers import handle_event_broadcast # noqa
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Event(BaseModel):
|
|
8
|
+
code: str
|
|
9
|
+
name: str
|
|
10
|
+
description: Optional[str] = None
|
|
11
|
+
info: Optional[dict] = None
|
|
12
|
+
|
|
13
|
+
class Config:
|
|
14
|
+
orm_mode = True
|
|
15
|
+
from_attributes = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Webhook(BaseModel):
|
|
19
|
+
code: str
|
|
20
|
+
name: str
|
|
21
|
+
endpoint: str
|
|
22
|
+
scope_type: str
|
|
23
|
+
scope_code: str
|
|
24
|
+
token: Optional[str] = None
|
|
25
|
+
extra_info: Optional[dict] = None
|
|
26
|
+
|
|
27
|
+
class Config:
|
|
28
|
+
orm_mode = True
|
|
29
|
+
from_attributes = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Scope(BaseModel):
|
|
33
|
+
type: str
|
|
34
|
+
code: str
|
|
35
|
+
|
|
36
|
+
class Config:
|
|
37
|
+
orm_mode = True
|
|
38
|
+
from_attributes = True
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
|
|
4
|
+
DEFAULT_SETTINGS = {
|
|
5
|
+
"MODE": "SYNC", # SYNC or ASYNC
|
|
6
|
+
"ALL_EVENTS_KEY": "*",
|
|
7
|
+
"EVENT": {
|
|
8
|
+
"DATA_SOURCE": "DB", # DB or SETTINGS
|
|
9
|
+
},
|
|
10
|
+
"REQUEST": {
|
|
11
|
+
"TIMEOUT": 30,
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WebhookSettings:
|
|
17
|
+
SETTING_PREFIX = "WEBHOOK"
|
|
18
|
+
NESTING_SEPARATOR = "_"
|
|
19
|
+
|
|
20
|
+
def __init__(self, default_settings=None):
|
|
21
|
+
self.project_settings = self.get_flatten_settings(getattr(settings, self.SETTING_PREFIX, {}))
|
|
22
|
+
self.default_settings = self.get_flatten_settings(default_settings or DEFAULT_SETTINGS)
|
|
23
|
+
|
|
24
|
+
def __getattr__(self, key):
|
|
25
|
+
if key not in self.project_settings and key not in self.default_settings:
|
|
26
|
+
raise AttributeError
|
|
27
|
+
|
|
28
|
+
value = self.project_settings.get(key) or self.default_settings.get(key)
|
|
29
|
+
if value is not None:
|
|
30
|
+
setattr(self, key, value)
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
def get_flatten_settings(self, inputted_settings: dict, cur_prefix: str = ""):
|
|
34
|
+
def get_cur_key(cur_key):
|
|
35
|
+
return f"{cur_prefix}{self.NESTING_SEPARATOR}{cur_key}" if cur_prefix else cur_key
|
|
36
|
+
|
|
37
|
+
flatten_settings = {}
|
|
38
|
+
for key, value in inputted_settings.items():
|
|
39
|
+
if isinstance(value, dict):
|
|
40
|
+
flatten_sub_settings = self.get_flatten_settings(value, key)
|
|
41
|
+
flatten_settings.update(
|
|
42
|
+
{
|
|
43
|
+
get_cur_key(flatten_key): flatten_value
|
|
44
|
+
for flatten_key, flatten_value in flatten_sub_settings.items()
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
flatten_settings[get_cur_key(key)] = value
|
|
49
|
+
return flatten_settings
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
webhook_settings = WebhookSettings(DEFAULT_SETTINGS)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from django.utils.translation import ugettext_lazy as _
|
|
3
|
+
from rest_framework import serializers
|
|
4
|
+
|
|
5
|
+
from webhook.config import webhook_settings
|
|
6
|
+
from webhook.models import Event
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WebhookSerializer(serializers.Serializer):
|
|
10
|
+
code = serializers.CharField(help_text=_("webhook编码"), max_length=255, required=True)
|
|
11
|
+
name = serializers.CharField(help_text=_("webhook名称"), max_length=255, required=True)
|
|
12
|
+
endpoint = serializers.URLField(help_text=_("webhook endpoint"), max_length=255, required=True)
|
|
13
|
+
token = serializers.CharField(help_text=_("webhook token"), max_length=255, required=False)
|
|
14
|
+
extra_info = serializers.JSONField(help_text=_("额外扩展信息"), required=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebhookConfigsSerializer(serializers.Serializer):
|
|
18
|
+
webhooks = serializers.ListField(help_text=_("webhook列表"), child=WebhookSerializer(), required=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WebhookWithEventsSerializer(WebhookSerializer):
|
|
22
|
+
events = serializers.ListField(help_text=_("webhook事件列表"), required=True)
|
|
23
|
+
|
|
24
|
+
def validate_events(self, events: list):
|
|
25
|
+
not_support_events = set(events) - set(Event.objects.all_events() + [webhook_settings.ALL_EVENTS_KEY])
|
|
26
|
+
if not_support_events:
|
|
27
|
+
raise serializers.ValidationError(_(f"校验失败,events中包含不支持的事件类型, 不支持事件类型: {not_support_events}"))
|
|
28
|
+
return events
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WebhookConfigsWithEventsSerializer(serializers.Serializer):
|
|
32
|
+
webhooks = serializers.ListField(help_text=_("webhook列表"), child=WebhookWithEventsSerializer(), required=True)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import abc
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Iterable, List
|
|
5
|
+
|
|
6
|
+
from django.db.models import Q
|
|
7
|
+
|
|
8
|
+
from webhook.base_models import Event, Scope, Webhook
|
|
9
|
+
from webhook.config import webhook_settings
|
|
10
|
+
from webhook.models import History, Subscription
|
|
11
|
+
from webhook.models import Webhook as WebHookModel
|
|
12
|
+
from webhook.requester import RequestConfig, Requester, RequestResult
|
|
13
|
+
from webhook.signals import (
|
|
14
|
+
post_event_broadcast_signal,
|
|
15
|
+
post_send_request_signal,
|
|
16
|
+
pre_event_broadcast_signal,
|
|
17
|
+
pre_send_request_signal,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HandlerInterface(abc.ABC):
|
|
22
|
+
@abc.abstractmethod
|
|
23
|
+
def handle(self, *args, **kwargs):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EventHandler(HandlerInterface):
|
|
28
|
+
def __init__(self, event: Event):
|
|
29
|
+
self.event = event
|
|
30
|
+
|
|
31
|
+
def handle(self, scopes: List[Scope], *args, **kwargs) -> None:
|
|
32
|
+
self._broadcast(scopes)
|
|
33
|
+
|
|
34
|
+
def _broadcast(self, scopes: List[Scope], *args, **kwargs):
|
|
35
|
+
pre_event_broadcast_signal.send(sender=self.__class__, event=self.event, scopes=scopes)
|
|
36
|
+
|
|
37
|
+
subscription_query = Q()
|
|
38
|
+
for scope in scopes:
|
|
39
|
+
subscription_query |= Q(
|
|
40
|
+
scope_type=scope.type,
|
|
41
|
+
scope_code=scope.code,
|
|
42
|
+
event_code__in=[self.event.code, webhook_settings.ALL_EVENTS_KEY],
|
|
43
|
+
)
|
|
44
|
+
subscriptions = (
|
|
45
|
+
list(
|
|
46
|
+
Subscription.objects.filter(subscription_query).values_list("webhook_code", "scope_type", "scope_code")
|
|
47
|
+
)
|
|
48
|
+
if scopes
|
|
49
|
+
else []
|
|
50
|
+
)
|
|
51
|
+
webhook_query = Q()
|
|
52
|
+
for webhook_code, scope_type, scope_code in subscriptions:
|
|
53
|
+
webhook_query |= Q(code=webhook_code, scope_type=scope_type, scope_code=scope_code)
|
|
54
|
+
webhooks = (
|
|
55
|
+
[Webhook.from_orm(webhook) for webhook in WebHookModel.objects.filter(webhook_query)]
|
|
56
|
+
if subscriptions
|
|
57
|
+
else []
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# TODO: 优化支持异步实现
|
|
61
|
+
self._sync_webhooker_handle(webhooks=webhooks)
|
|
62
|
+
|
|
63
|
+
post_event_broadcast_signal.send(sender=self.__class__, event=self.event, scopes=scopes)
|
|
64
|
+
|
|
65
|
+
def _sync_webhooker_handle(self, webhooks: Iterable[Webhook], *args, **kwargs):
|
|
66
|
+
for webhook in webhooks:
|
|
67
|
+
webhooker = Webhooker(webhook)
|
|
68
|
+
webhooker.handle(event=self.event, *args, **kwargs)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Webhooker(HandlerInterface):
|
|
72
|
+
def __init__(self, webhook: Webhook):
|
|
73
|
+
self.webhook = webhook
|
|
74
|
+
|
|
75
|
+
def handle(self, event: Event, *args, **kwargs) -> RequestResult:
|
|
76
|
+
return self._request(event=event, *args, **kwargs)
|
|
77
|
+
|
|
78
|
+
def _request(self, event: Event, *args, **kwargs):
|
|
79
|
+
pre_send_request_signal.send(sender=self.__class__, webhook=self.webhook, event=event)
|
|
80
|
+
delivery_id = kwargs.pop("delivery_id", uuid.uuid4().hex)
|
|
81
|
+
headers = kwargs.pop("headers", {})
|
|
82
|
+
request_config = RequestConfig(
|
|
83
|
+
url=self.webhook.endpoint, data={"event": event.dict(), "delivery_id": delivery_id}, headers=headers
|
|
84
|
+
)
|
|
85
|
+
request_result: RequestResult = Requester(config=request_config).request()
|
|
86
|
+
history_extra_info = {
|
|
87
|
+
"request": request_config.dict(),
|
|
88
|
+
"response": request_result.json_response(),
|
|
89
|
+
**kwargs.get("history_extra_info", {}),
|
|
90
|
+
}
|
|
91
|
+
# TODO: 可配置
|
|
92
|
+
History.objects.create(
|
|
93
|
+
webhook_code=self.webhook.code,
|
|
94
|
+
event_code=event.code,
|
|
95
|
+
success=request_result.ok,
|
|
96
|
+
status_code=request_result.response_status_code,
|
|
97
|
+
delivery_id=delivery_id,
|
|
98
|
+
scope_type=self.webhook.scope_type,
|
|
99
|
+
scope_code=self.webhook.scope_code,
|
|
100
|
+
extra_info=history_extra_info,
|
|
101
|
+
)
|
|
102
|
+
post_send_request_signal.send(sender=self.__class__, webhook=self.webhook, event=event)
|
|
103
|
+
return request_result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from os import path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
from django.core.management.base import BaseCommand
|
|
6
|
+
|
|
7
|
+
from webhook.models import Event
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Command(BaseCommand):
|
|
11
|
+
"""
|
|
12
|
+
python manage.py sync_webhook_events base_path filename
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def add_arguments(self, parser):
|
|
16
|
+
parser.add_argument("base_path", type=str, help="base_path")
|
|
17
|
+
parser.add_argument("filename", type=str, help="filename")
|
|
18
|
+
|
|
19
|
+
def handle(self, *args, **kwargs):
|
|
20
|
+
base_path = kwargs["base_path"]
|
|
21
|
+
filename = kwargs["filename"]
|
|
22
|
+
self.stdout.write(f"sync events and scopes with filename: {filename}")
|
|
23
|
+
|
|
24
|
+
with open(path.join(base_path, filename), "r", encoding="utf-8") as f:
|
|
25
|
+
webhook_config = yaml.load(f, Loader=yaml.FullLoader)
|
|
26
|
+
|
|
27
|
+
# sync events
|
|
28
|
+
events = webhook_config.get("events", [])
|
|
29
|
+
for event in events:
|
|
30
|
+
if not event.get("code"):
|
|
31
|
+
self.stdout.error(f"event code is required, event: {event}")
|
|
32
|
+
continue
|
|
33
|
+
Event.objects.get_or_create(
|
|
34
|
+
code=event["code"], name=event["name"], description=event.get("description", "")
|
|
35
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Generated by Django 3.2.15 on 2023-07-15 17:58
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.CreateModel(
|
|
14
|
+
name="Event",
|
|
15
|
+
fields=[
|
|
16
|
+
("code", models.CharField(max_length=255, primary_key=True, serialize=False)),
|
|
17
|
+
("name", models.CharField(max_length=255)),
|
|
18
|
+
("description", models.TextField(blank=True, null=True)),
|
|
19
|
+
("info", models.JSONField(blank=True, null=True)),
|
|
20
|
+
],
|
|
21
|
+
),
|
|
22
|
+
migrations.CreateModel(
|
|
23
|
+
name="Webhook",
|
|
24
|
+
fields=[
|
|
25
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
26
|
+
("code", models.CharField(max_length=255)),
|
|
27
|
+
("name", models.CharField(max_length=255)),
|
|
28
|
+
("endpoint", models.URLField()),
|
|
29
|
+
("scope_type", models.CharField(max_length=64)),
|
|
30
|
+
("scope_code", models.CharField(max_length=64)),
|
|
31
|
+
("token", models.CharField(blank=True, max_length=255, null=True)),
|
|
32
|
+
("extra_info", models.JSONField(blank=True, null=True)),
|
|
33
|
+
],
|
|
34
|
+
options={
|
|
35
|
+
"unique_together": {("scope_type", "scope_code", "code")},
|
|
36
|
+
},
|
|
37
|
+
),
|
|
38
|
+
migrations.CreateModel(
|
|
39
|
+
name="Subscription",
|
|
40
|
+
fields=[
|
|
41
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
42
|
+
("webhook_code", models.CharField(db_index=True, max_length=255)),
|
|
43
|
+
("event_code", models.CharField(db_index=True, max_length=255)),
|
|
44
|
+
("scope_type", models.CharField(max_length=64)),
|
|
45
|
+
("scope_code", models.CharField(max_length=64)),
|
|
46
|
+
],
|
|
47
|
+
options={
|
|
48
|
+
"index_together": {("scope_type", "scope_code", "event_code", "webhook_code")},
|
|
49
|
+
},
|
|
50
|
+
),
|
|
51
|
+
migrations.CreateModel(
|
|
52
|
+
name="Scope",
|
|
53
|
+
fields=[
|
|
54
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
55
|
+
("type", models.CharField(max_length=64)),
|
|
56
|
+
("code", models.CharField(max_length=64)),
|
|
57
|
+
],
|
|
58
|
+
options={
|
|
59
|
+
"unique_together": {("type", "code")},
|
|
60
|
+
},
|
|
61
|
+
),
|
|
62
|
+
migrations.CreateModel(
|
|
63
|
+
name="History",
|
|
64
|
+
fields=[
|
|
65
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
66
|
+
("webhook_code", models.CharField(db_index=True, max_length=255)),
|
|
67
|
+
("event_code", models.CharField(db_index=True, max_length=255)),
|
|
68
|
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
69
|
+
("success", models.BooleanField()),
|
|
70
|
+
("status_code", models.IntegerField(blank=True, default=None, null=True)),
|
|
71
|
+
("delivery_id", models.CharField(db_index=True, max_length=64)),
|
|
72
|
+
("scope_type", models.CharField(max_length=64)),
|
|
73
|
+
("scope_code", models.CharField(max_length=64)),
|
|
74
|
+
("extra_info", models.JSONField(blank=True, null=True)),
|
|
75
|
+
],
|
|
76
|
+
options={
|
|
77
|
+
"index_together": {("scope_type", "scope_code", "webhook_code", "event_code")},
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated by Django 3.2.15 on 2024-02-02 03:48
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('webhook', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterModelOptions(
|
|
14
|
+
name='history',
|
|
15
|
+
options={'ordering': ['-id']},
|
|
16
|
+
),
|
|
17
|
+
migrations.AlterModelOptions(
|
|
18
|
+
name='subscription',
|
|
19
|
+
options={'ordering': ['-id']},
|
|
20
|
+
),
|
|
21
|
+
migrations.AlterModelOptions(
|
|
22
|
+
name='webhook',
|
|
23
|
+
options={'ordering': ['-id']},
|
|
24
|
+
),
|
|
25
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from typing import Dict, List
|
|
3
|
+
|
|
4
|
+
from django.db import models, transaction
|
|
5
|
+
from django.db.models import Q
|
|
6
|
+
|
|
7
|
+
from webhook.base_models import Scope as ScopeBaseModel
|
|
8
|
+
from webhook.base_models import Webhook as WebhookBaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WebhookManager(models.Manager):
|
|
12
|
+
def apply_scope_webhooks(self, scope: ScopeBaseModel, webhooks: List[WebhookBaseModel]):
|
|
13
|
+
"""
|
|
14
|
+
apply the given webhooks to the specified scope
|
|
15
|
+
need to guarantee the scope in webhooks is the same as the scope parameter
|
|
16
|
+
"""
|
|
17
|
+
codes = set([webhook.code for webhook in webhooks])
|
|
18
|
+
with transaction.atomic():
|
|
19
|
+
if not Scope.objects.filter(type=scope.type, code=scope.code).exists():
|
|
20
|
+
Scope.objects.create(type=scope.type, code=scope.code)
|
|
21
|
+
# delete ones
|
|
22
|
+
self.filter(scope_type=scope.type, scope_code=scope.code).exclude(code__in=codes).delete()
|
|
23
|
+
|
|
24
|
+
# create or update ones
|
|
25
|
+
existing_webhook_info = self.filter(scope_type=scope.type, scope_code=scope.code).values("code", "id")
|
|
26
|
+
existing_webhook_code2id = {info["code"]: info["id"] for info in existing_webhook_info}
|
|
27
|
+
create_ones = [
|
|
28
|
+
Webhook(**webhook.dict()) for webhook in webhooks if webhook.code not in existing_webhook_code2id
|
|
29
|
+
]
|
|
30
|
+
self.bulk_create(create_ones)
|
|
31
|
+
# update ones
|
|
32
|
+
update_ones = [
|
|
33
|
+
Webhook(id=existing_webhook_code2id[webhook.code], **webhook.dict())
|
|
34
|
+
for webhook in webhooks
|
|
35
|
+
if webhook.code in existing_webhook_code2id
|
|
36
|
+
]
|
|
37
|
+
self.bulk_update(update_ones, fields=["name", "endpoint", "token", "extra_info"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Webhook(models.Model):
|
|
41
|
+
code = models.CharField(max_length=255)
|
|
42
|
+
name = models.CharField(max_length=255)
|
|
43
|
+
endpoint = models.URLField()
|
|
44
|
+
scope_type = models.CharField(max_length=64)
|
|
45
|
+
scope_code = models.CharField(max_length=64)
|
|
46
|
+
token = models.CharField(max_length=255, null=True, blank=True)
|
|
47
|
+
extra_info = models.JSONField(null=True, blank=True)
|
|
48
|
+
|
|
49
|
+
objects = WebhookManager()
|
|
50
|
+
|
|
51
|
+
class Meta:
|
|
52
|
+
unique_together = ("scope_type", "scope_code", "code")
|
|
53
|
+
ordering = ["-id"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class History(models.Model):
|
|
57
|
+
webhook_code = models.CharField(max_length=255, db_index=True)
|
|
58
|
+
event_code = models.CharField(max_length=255, db_index=True)
|
|
59
|
+
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
|
60
|
+
success = models.BooleanField()
|
|
61
|
+
status_code = models.IntegerField(null=True, blank=True, default=None)
|
|
62
|
+
delivery_id = models.CharField(max_length=64, db_index=True)
|
|
63
|
+
scope_type = models.CharField(max_length=64)
|
|
64
|
+
scope_code = models.CharField(max_length=64)
|
|
65
|
+
extra_info = models.JSONField(null=True, blank=True) # request、response、time
|
|
66
|
+
|
|
67
|
+
class Meta:
|
|
68
|
+
index_together = ("scope_type", "scope_code", "webhook_code", "event_code")
|
|
69
|
+
ordering = ["-id"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SubscriptionManager(models.Manager):
|
|
73
|
+
def apply_scope_subscriptions(self, scope: ScopeBaseModel, subscription_configs: Dict[str, List[str]]):
|
|
74
|
+
"""
|
|
75
|
+
subscription_configs: {webhook_code: [event_code]}
|
|
76
|
+
"""
|
|
77
|
+
with transaction.atomic():
|
|
78
|
+
flatten_subscription_configs = [
|
|
79
|
+
(webhook_code, event_code)
|
|
80
|
+
for webhook_code, event_codes in subscription_configs.items()
|
|
81
|
+
for event_code in event_codes
|
|
82
|
+
]
|
|
83
|
+
query_filter = Q()
|
|
84
|
+
for webhook_code, event_code in flatten_subscription_configs:
|
|
85
|
+
query_filter |= Q(webhook_code=webhook_code, event_code=event_code)
|
|
86
|
+
|
|
87
|
+
# delete
|
|
88
|
+
self.filter(scope_type=scope.type, scope_code=scope.code).exclude(query_filter).delete()
|
|
89
|
+
|
|
90
|
+
# create
|
|
91
|
+
existing_ones = self.filter(scope_type=scope.type, scope_code=scope.code).values_list(
|
|
92
|
+
"webhook_code", "event_code"
|
|
93
|
+
)
|
|
94
|
+
create_configs = set(flatten_subscription_configs) - set(existing_ones)
|
|
95
|
+
create_ones = [
|
|
96
|
+
Subscription(
|
|
97
|
+
scope_type=scope.type, scope_code=scope.code, webhook_code=webhook_code, event_code=event_code
|
|
98
|
+
)
|
|
99
|
+
for webhook_code, event_code in create_configs
|
|
100
|
+
]
|
|
101
|
+
self.bulk_create(create_ones)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Subscription(models.Model):
|
|
105
|
+
webhook_code = models.CharField(max_length=255, db_index=True)
|
|
106
|
+
event_code = models.CharField(max_length=255, db_index=True)
|
|
107
|
+
scope_type = models.CharField(max_length=64)
|
|
108
|
+
scope_code = models.CharField(max_length=64)
|
|
109
|
+
|
|
110
|
+
objects = SubscriptionManager()
|
|
111
|
+
|
|
112
|
+
class Meta:
|
|
113
|
+
index_together = ("scope_type", "scope_code", "event_code", "webhook_code")
|
|
114
|
+
ordering = ["-id"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class EventManager(models.Manager):
|
|
118
|
+
def all_events(self) -> list:
|
|
119
|
+
return list(self.all().values_list("code", flat=True))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Event(models.Model):
|
|
123
|
+
code = models.CharField(max_length=255, primary_key=True)
|
|
124
|
+
name = models.CharField(max_length=255)
|
|
125
|
+
description = models.TextField(null=True, blank=True)
|
|
126
|
+
info = models.JSONField(null=True, blank=True)
|
|
127
|
+
|
|
128
|
+
objects = EventManager()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Scope(models.Model):
|
|
132
|
+
type = models.CharField(max_length=64)
|
|
133
|
+
code = models.CharField(max_length=64)
|
|
134
|
+
|
|
135
|
+
class Meta:
|
|
136
|
+
unique_together = ("type", "code")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
10
|
+
FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RequestConfig(BaseModel):
|
|
14
|
+
url: str
|
|
15
|
+
method: str = "post"
|
|
16
|
+
content_type: str = JSON_CONTENT_TYPE
|
|
17
|
+
headers: dict = None
|
|
18
|
+
data: dict = {} # convert to json when content_type is JSON_CONTENT_TYPE
|
|
19
|
+
verify: bool = False
|
|
20
|
+
timeout: int = None
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _gen_default_headers(content_type):
|
|
24
|
+
return {"Content-Type": content_type}
|
|
25
|
+
|
|
26
|
+
def __init__(self, **kwargs):
|
|
27
|
+
kwargs["headers"] = {
|
|
28
|
+
**self._gen_default_headers(content_type=kwargs.get("content_type", JSON_CONTENT_TYPE)),
|
|
29
|
+
**kwargs.get("headers", {}),
|
|
30
|
+
}
|
|
31
|
+
super().__init__(**kwargs)
|
|
32
|
+
|
|
33
|
+
def dict(self, **kwargs):
|
|
34
|
+
result = super().dict(**kwargs)
|
|
35
|
+
content_type = result.pop("content_type")
|
|
36
|
+
if content_type == JSON_CONTENT_TYPE:
|
|
37
|
+
result["json"] = result.pop("data")
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RequestResult(BaseModel):
|
|
42
|
+
result: bool
|
|
43
|
+
response: requests.Response = None
|
|
44
|
+
exe_data: str = None
|
|
45
|
+
|
|
46
|
+
class Config:
|
|
47
|
+
arbitrary_types_allowed = True
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def ok(self):
|
|
51
|
+
return self.result and self.response.ok
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def response_status_code(self):
|
|
55
|
+
if self.response is None:
|
|
56
|
+
return None
|
|
57
|
+
return self.response.status_code
|
|
58
|
+
|
|
59
|
+
def json_response(self):
|
|
60
|
+
if self.response is None:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return self.response.json()
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.exception(f"[RequestResult.json_response error] {e}")
|
|
66
|
+
return self.response.text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Requester:
|
|
70
|
+
def __init__(self, config: RequestConfig):
|
|
71
|
+
self.request_config = config
|
|
72
|
+
|
|
73
|
+
def request(self, *args, **kwargs) -> RequestResult:
|
|
74
|
+
try:
|
|
75
|
+
response = requests.request(**self.request_config.dict())
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.exception(f"[Requester.request error] {e}")
|
|
78
|
+
return RequestResult(result=False, exe_data=str(e))
|
|
79
|
+
else:
|
|
80
|
+
return RequestResult(result=True, response=response)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from django.dispatch import Signal
|
|
4
|
+
|
|
5
|
+
event_broadcast_signal = Signal(providing_args=["scopes", "extra_info"])
|
|
6
|
+
|
|
7
|
+
pre_event_broadcast_signal = Signal(providing_args=["event", "scope"])
|
|
8
|
+
post_event_broadcast_signal = Signal(providing_args=["event", "scope"])
|
|
9
|
+
pre_send_request_signal = Signal(providing_args=["webhook", "event"])
|
|
10
|
+
post_send_request_signal = Signal(providing_args=["webhook", "event"])
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from typing import List, Tuple, Union
|
|
3
|
+
|
|
4
|
+
from django.dispatch import receiver
|
|
5
|
+
|
|
6
|
+
from webhook.api import event_broadcast
|
|
7
|
+
from webhook.base_models import Event, Scope
|
|
8
|
+
from webhook.signals import event_broadcast_signal
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@receiver(event_broadcast_signal)
|
|
12
|
+
def handle_event_broadcast(
|
|
13
|
+
sender: Union[Event, str], scopes: List[Union[Scope, Tuple[str, str]]], extra_info: dict = None, **kwargs
|
|
14
|
+
):
|
|
15
|
+
event_broadcast(event=sender, scopes=scopes, extra_info=extra_info)
|