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.
Files changed (25) hide show
  1. bkflow_django_webhook-1.0.0/PKG-INFO +133 -0
  2. bkflow_django_webhook-1.0.0/README.md +118 -0
  3. bkflow_django_webhook-1.0.0/pyproject.toml +36 -0
  4. bkflow_django_webhook-1.0.0/setup.py +37 -0
  5. bkflow_django_webhook-1.0.0/webhook/__init__.py +3 -0
  6. bkflow_django_webhook-1.0.0/webhook/admin.py +37 -0
  7. bkflow_django_webhook-1.0.0/webhook/api.py +67 -0
  8. bkflow_django_webhook-1.0.0/webhook/apps.py +16 -0
  9. bkflow_django_webhook-1.0.0/webhook/base_models.py +38 -0
  10. bkflow_django_webhook-1.0.0/webhook/config.py +52 -0
  11. bkflow_django_webhook-1.0.0/webhook/contrib/__init__.py +1 -0
  12. bkflow_django_webhook-1.0.0/webhook/contrib/drf/__init__.py +1 -0
  13. bkflow_django_webhook-1.0.0/webhook/contrib/drf/serializers.py +32 -0
  14. bkflow_django_webhook-1.0.0/webhook/handlers.py +103 -0
  15. bkflow_django_webhook-1.0.0/webhook/management/__init__.py +1 -0
  16. bkflow_django_webhook-1.0.0/webhook/management/commands/__init__.py +1 -0
  17. bkflow_django_webhook-1.0.0/webhook/management/commands/sync_webhook_events.py +35 -0
  18. bkflow_django_webhook-1.0.0/webhook/migrations/0001_initial.py +80 -0
  19. bkflow_django_webhook-1.0.0/webhook/migrations/0002_auto_20240202_1148.py +25 -0
  20. bkflow_django_webhook-1.0.0/webhook/migrations/__init__.py +0 -0
  21. bkflow_django_webhook-1.0.0/webhook/models.py +136 -0
  22. bkflow_django_webhook-1.0.0/webhook/requester/__init__.py +3 -0
  23. bkflow_django_webhook-1.0.0/webhook/requester/base.py +80 -0
  24. bkflow_django_webhook-1.0.0/webhook/signals/__init__.py +10 -0
  25. 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
+ ![bkflow_django_webhook_concepts](docs/pics/bkflow_django_webhook_concepts.png)
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
+ ![bkflow_django_webhook_concepts](docs/pics/bkflow_django_webhook_concepts.png)
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,3 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ __version__ = "1.0.0"
@@ -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
+ ]
@@ -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,3 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from .base import RequestConfig, Requester, RequestResult # noqa
@@ -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)