camunda-python 0.1.0__py3-none-any.whl
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.
- camunda/__init__.py +10 -0
- camunda/api/__init__.py +14 -0
- camunda/api/app.py +80 -0
- camunda/api/deps.py +17 -0
- camunda/api/errors.py +84 -0
- camunda/api/pagination.py +74 -0
- camunda/api/routers/__init__.py +5 -0
- camunda/api/routers/decision.py +57 -0
- camunda/api/routers/deployment.py +139 -0
- camunda/api/routers/history.py +128 -0
- camunda/api/routers/process_definition.py +49 -0
- camunda/api/routers/process_instance.py +106 -0
- camunda/api/routers/task.py +92 -0
- camunda/api/schemas.py +200 -0
- camunda/common/__init__.py +19 -0
- camunda/common/clock.py +30 -0
- camunda/common/exceptions.py +34 -0
- camunda/common/idgen.py +23 -0
- camunda/common/timers.py +106 -0
- camunda/dmn/__init__.py +5 -0
- camunda/dmn/engine.py +219 -0
- camunda/dmn/feel.py +392 -0
- camunda/engine/__init__.py +9 -0
- camunda/engine/behavior.py +51 -0
- camunda/engine/expression.py +126 -0
- camunda/engine/process_engine.py +3237 -0
- camunda/job/__init__.py +9 -0
- camunda/job/executor.py +136 -0
- camunda/model/__init__.py +48 -0
- camunda/model/bpmn.py +326 -0
- camunda/model/dmn.py +101 -0
- camunda/model/execution.py +121 -0
- camunda/model/job.py +88 -0
- camunda/model/task.py +33 -0
- camunda/model/variable.py +35 -0
- camunda/parser/__init__.py +5 -0
- camunda/parser/bpmn_parser.py +646 -0
- camunda/parser/dmn_parser.py +225 -0
- camunda/persistence/__init__.py +21 -0
- camunda/persistence/entities.py +202 -0
- camunda/persistence/store.py +721 -0
- camunda_python-0.1.0.dist-info/METADATA +377 -0
- camunda_python-0.1.0.dist-info/RECORD +46 -0
- camunda_python-0.1.0.dist-info/WHEEL +5 -0
- camunda_python-0.1.0.dist-info/licenses/LICENSE +200 -0
- camunda_python-0.1.0.dist-info/top_level.txt +1 -0
camunda/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""camunda-python · Camunda 7 BPMN engine reimplementation in Python 3.
|
|
2
|
+
|
|
3
|
+
Semantic-aligned independent implementation (Apache-2.0). Reference baseline:
|
|
4
|
+
Camunda 7.23.0 (final open-source community edition).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
# Engine version reported to align with Camunda semantics we target.
|
|
10
|
+
CAMUNDA_COMPAT_VERSION = "7.23.0"
|
camunda/api/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""api 包:REST 兼容层(M6 里程碑交付:FastAPI 对齐 Camunda engine-rest 常用端点)。
|
|
2
|
+
|
|
3
|
+
用法:
|
|
4
|
+
from camunda.api import create_app
|
|
5
|
+
app = create_app() # 内存引擎
|
|
6
|
+
app = create_app(engine=engine) # 复用既有引擎(含 Store / JobExecutor)
|
|
7
|
+
|
|
8
|
+
启动:
|
|
9
|
+
uvicorn camunda.api.app:create_app --factory --port 8080
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from camunda.api.app import DEFAULT_PREFIX, create_app
|
|
13
|
+
|
|
14
|
+
__all__ = ["create_app", "DEFAULT_PREFIX"]
|
camunda/api/app.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""FastAPI 应用工厂(M6-1)。
|
|
2
|
+
|
|
3
|
+
用法:
|
|
4
|
+
from camunda.api import create_app
|
|
5
|
+
app = create_app() # 内存引擎,可直接 uvicorn 跑
|
|
6
|
+
app = create_app(engine=my_engine) # 复用已有引擎(含持久化/作业执行器)
|
|
7
|
+
|
|
8
|
+
端点总前缀默认 `/engine-rest`(对齐 Camunda 7 REST),可用 prefix 覆盖。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, FastAPI
|
|
16
|
+
|
|
17
|
+
from camunda import CAMUNDA_COMPAT_VERSION, __version__
|
|
18
|
+
from camunda.api.errors import register_exception_handlers
|
|
19
|
+
from camunda.api.routers import (
|
|
20
|
+
decision,
|
|
21
|
+
deployment,
|
|
22
|
+
history,
|
|
23
|
+
process_definition,
|
|
24
|
+
process_instance,
|
|
25
|
+
task,
|
|
26
|
+
)
|
|
27
|
+
from camunda.engine import ProcessEngine
|
|
28
|
+
|
|
29
|
+
DEFAULT_PREFIX = "/engine-rest"
|
|
30
|
+
|
|
31
|
+
_DESCRIPTION = f"""\
|
|
32
|
+
camunda-python 的 REST 兼容层(M6)。
|
|
33
|
+
|
|
34
|
+
对齐 Camunda {CAMUNDA_COMPAT_VERSION} engine-rest 的常用端点子集(文档化差异见
|
|
35
|
+
docs/ARCHITECTURE.md 的 M6 交付记录)。
|
|
36
|
+
|
|
37
|
+
- 变量入参兼容两种写法:包装形态 `{{"amount": {{"value": 1, "type": "Long"}}}}` 与裸值
|
|
38
|
+
`{{"amount": 1}}`;出参默认包装形态,带 `?bare=true` 退化为裸值 map。
|
|
39
|
+
- 错误响应体统一为 `{{"type": "<异常类名>", "message": "<异常消息>"}}`。
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_app(
|
|
44
|
+
engine: Optional[ProcessEngine] = None,
|
|
45
|
+
prefix: str = DEFAULT_PREFIX,
|
|
46
|
+
title: str = "camunda-python REST API",
|
|
47
|
+
) -> FastAPI:
|
|
48
|
+
"""构造 REST 应用。
|
|
49
|
+
|
|
50
|
+
engine 为 None 时内部新建一个内存引擎(ProcessEngine()),适合 demo/测试;
|
|
51
|
+
生产用法传入带 Store 的引擎实例即可(路由只依赖引擎门面方法)。
|
|
52
|
+
"""
|
|
53
|
+
app = FastAPI(title=title, version=__version__, description=_DESCRIPTION)
|
|
54
|
+
# 路由通过 request.app.state.engine 取引擎(见 camunda/api/deps.py)
|
|
55
|
+
app.state.engine = engine if engine is not None else ProcessEngine()
|
|
56
|
+
register_exception_handlers(app)
|
|
57
|
+
|
|
58
|
+
api = APIRouter(prefix=prefix)
|
|
59
|
+
api.include_router(deployment.router)
|
|
60
|
+
api.include_router(process_definition.router)
|
|
61
|
+
api.include_router(process_instance.router)
|
|
62
|
+
api.include_router(task.router)
|
|
63
|
+
api.include_router(history.router)
|
|
64
|
+
api.include_router(decision.router)
|
|
65
|
+
app.include_router(api)
|
|
66
|
+
|
|
67
|
+
@app.get("/", tags=["meta"], summary="服务元信息")
|
|
68
|
+
def index() -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"name": "camunda-python",
|
|
71
|
+
"version": __version__,
|
|
72
|
+
"camundaCompat": CAMUNDA_COMPAT_VERSION,
|
|
73
|
+
"engineRestPrefix": prefix,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@app.get("/health", tags=["meta"], summary="健康检查")
|
|
77
|
+
def health() -> dict:
|
|
78
|
+
return {"status": "UP"}
|
|
79
|
+
|
|
80
|
+
return app
|
camunda/api/deps.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""REST 层依赖注入(M6-1)。
|
|
2
|
+
|
|
3
|
+
引擎实例挂在 app.state.engine(进程内单例),路由通过本模块取用,避免 routers
|
|
4
|
+
与 app 互相 import 造成循环依赖。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
|
|
11
|
+
from camunda.engine import ProcessEngine
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_engine(request: Request) -> ProcessEngine:
|
|
15
|
+
"""取应用绑定的引擎实例(create_app 时挂到 app.state.engine)。"""
|
|
16
|
+
engine: ProcessEngine = request.app.state.engine
|
|
17
|
+
return engine
|
camunda/api/errors.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Camunda 异常层次 -> HTTP 状态码映射(M6-1)。
|
|
2
|
+
|
|
3
|
+
对齐 Camunda 7 REST 的错误响应体:
|
|
4
|
+
{"type": "<异常类名>", "message": "<异常消息>"}
|
|
5
|
+
|
|
6
|
+
状态码决策(对齐 Camunda REST 常用语义,文档化差异见 docs/ARCHITECTURE.md):
|
|
7
|
+
- NotFoundException 404 按 id/key 查不到对象
|
|
8
|
+
- DeploymentException 400 BPMN/DMN XML 语法错误或语义校验不通过
|
|
9
|
+
- InvalidRequestException 400 参数或调用不合法
|
|
10
|
+
- ProcessInstanceException 409 实例状态冲突(对已完成实例操作 / 定时启动流程手动启动)
|
|
11
|
+
- ExpressionEvaluationException 400 FEEL/UEL 求值失败
|
|
12
|
+
- CamundaException(兜底) 500 其余未分类引擎异常
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, Type
|
|
18
|
+
|
|
19
|
+
from fastapi import FastAPI, Request
|
|
20
|
+
from fastapi.responses import JSONResponse
|
|
21
|
+
|
|
22
|
+
from camunda.common.exceptions import (
|
|
23
|
+
CamundaException,
|
|
24
|
+
DeploymentException,
|
|
25
|
+
ExpressionEvaluationException,
|
|
26
|
+
InvalidRequestException,
|
|
27
|
+
NotFoundException,
|
|
28
|
+
ProcessInstanceException,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# 异常类 -> HTTP 状态码(子类查找按 MRO 顺序从最具体开始)
|
|
32
|
+
_STATUS_MAP: Dict[Type[BaseException], int] = {
|
|
33
|
+
NotFoundException: 404,
|
|
34
|
+
DeploymentException: 400,
|
|
35
|
+
InvalidRequestException: 400,
|
|
36
|
+
ProcessInstanceException: 409,
|
|
37
|
+
ExpressionEvaluationException: 400,
|
|
38
|
+
CamundaException: 500,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def status_for(exc: BaseException) -> int:
|
|
43
|
+
"""按异常类型解析 HTTP 状态码(未知引擎异常兜底 500)。"""
|
|
44
|
+
for cls in type(exc).__mro__:
|
|
45
|
+
if cls in _STATUS_MAP:
|
|
46
|
+
return _STATUS_MAP[cls]
|
|
47
|
+
return 500
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def error_body(exc: BaseException) -> Dict[str, Any]:
|
|
51
|
+
"""构造 Camunda 风格错误响应体。"""
|
|
52
|
+
return {"type": type(exc).__name__, "message": str(exc)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
56
|
+
"""注册 CamundaException 全局处理器(FastAPI 按 MRO 派发最近的 handler)。"""
|
|
57
|
+
|
|
58
|
+
@app.exception_handler(NotFoundException)
|
|
59
|
+
async def _not_found(request: Request, exc: NotFoundException) -> JSONResponse:
|
|
60
|
+
return JSONResponse(status_code=404, content=error_body(exc))
|
|
61
|
+
|
|
62
|
+
@app.exception_handler(DeploymentException)
|
|
63
|
+
async def _deployment(request: Request, exc: DeploymentException) -> JSONResponse:
|
|
64
|
+
return JSONResponse(status_code=400, content=error_body(exc))
|
|
65
|
+
|
|
66
|
+
@app.exception_handler(InvalidRequestException)
|
|
67
|
+
async def _invalid(request: Request, exc: InvalidRequestException) -> JSONResponse:
|
|
68
|
+
return JSONResponse(status_code=400, content=error_body(exc))
|
|
69
|
+
|
|
70
|
+
@app.exception_handler(ProcessInstanceException)
|
|
71
|
+
async def _conflict(
|
|
72
|
+
request: Request, exc: ProcessInstanceException
|
|
73
|
+
) -> JSONResponse:
|
|
74
|
+
return JSONResponse(status_code=409, content=error_body(exc))
|
|
75
|
+
|
|
76
|
+
@app.exception_handler(ExpressionEvaluationException)
|
|
77
|
+
async def _expression(
|
|
78
|
+
request: Request, exc: ExpressionEvaluationException
|
|
79
|
+
) -> JSONResponse:
|
|
80
|
+
return JSONResponse(status_code=400, content=error_body(exc))
|
|
81
|
+
|
|
82
|
+
@app.exception_handler(CamundaException)
|
|
83
|
+
async def _camunda(request: Request, exc: CamundaException) -> JSONResponse:
|
|
84
|
+
return JSONResponse(status_code=500, content=error_body(exc))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""REST 分页(M8)。
|
|
2
|
+
|
|
3
|
+
对齐 Camunda 7 REST 分页约定:
|
|
4
|
+
- 查询参数:`firstResult`(0 基偏移,默认 0)+ `maxResults`(每页上限,默认 200)
|
|
5
|
+
- 响应:仍是裸数组(**不**回包 count/total),调用方通过「结果数 < maxResults」判定到达末页
|
|
6
|
+
- 负值 / 越界自动 clamp 到合法区间(Camunda 对非法 firstResult 直接抛 400,
|
|
7
|
+
本项目为易用性选 clamp,文档化差异——见 docs/ARCHITECTURE.md)
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
|
|
11
|
+
@router.get("/foo")
|
|
12
|
+
def list_foo(
|
|
13
|
+
request: Request,
|
|
14
|
+
firstResult: int = Query(default=0, ge=0),
|
|
15
|
+
maxResults: int = Query(default=DEFAULT_MAX_RESULTS, ge=1, le=MAX_RESULTS_LIMIT),
|
|
16
|
+
):
|
|
17
|
+
items = ...
|
|
18
|
+
return paginate(items, firstResult, maxResults)
|
|
19
|
+
|
|
20
|
+
设计动机:本项目 M6 列表端点全部 `return list`——分页前移到这里实现
|
|
21
|
+
一遍,所有路由都用同一份语义与默认值,避免每个端点各写一份。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Dict, List, TypeVar
|
|
27
|
+
|
|
28
|
+
# Camunda 默认对 maxResults 不设硬上限;本项目为防止脚本误用拉空内存,
|
|
29
|
+
# 取一个 Camunda 文档中常见的"列表默认上限 200"作为软上限。
|
|
30
|
+
DEFAULT_MAX_RESULTS = 200
|
|
31
|
+
MAX_RESULTS_LIMIT = 1000 # 单次请求硬上限,超过会被 clamp 到这里
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_pagination(
|
|
37
|
+
first_result: int, max_results: int
|
|
38
|
+
) -> Dict[str, int]:
|
|
39
|
+
"""把入参 clamp 到合法区间,返回 {firstResult, maxResults} dict。
|
|
40
|
+
|
|
41
|
+
- firstResult < 0 -> 0
|
|
42
|
+
- maxResults < 1 -> DEFAULT_MAX_RESULTS(前端误传 0/负数)
|
|
43
|
+
- maxResults > MAX_RESULTS_LIMIT -> MAX_RESULTS_LIMIT
|
|
44
|
+
"""
|
|
45
|
+
fr = max(0, int(first_result))
|
|
46
|
+
mr = int(max_results) if max_results is not None else DEFAULT_MAX_RESULTS
|
|
47
|
+
if mr < 1:
|
|
48
|
+
mr = DEFAULT_MAX_RESULTS
|
|
49
|
+
if mr > MAX_RESULTS_LIMIT:
|
|
50
|
+
mr = MAX_RESULTS_LIMIT
|
|
51
|
+
return {"firstResult": fr, "maxResults": mr}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def paginate(
|
|
55
|
+
items: List[T], first_result: int, max_results: int
|
|
56
|
+
) -> List[T]:
|
|
57
|
+
"""对列表做切片(闭区间语义)。
|
|
58
|
+
|
|
59
|
+
返回值仍是裸列表,便于 FastAPI 直接 JSON 序列化。
|
|
60
|
+
"""
|
|
61
|
+
norm = normalize_pagination(first_result, max_results)
|
|
62
|
+
fr, mr = norm["firstResult"], norm["maxResults"]
|
|
63
|
+
# 起点超过尾:返回空数组(Camunda 行为相同)
|
|
64
|
+
if fr >= len(items):
|
|
65
|
+
return []
|
|
66
|
+
return items[fr : fr + mr]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"DEFAULT_MAX_RESULTS",
|
|
71
|
+
"MAX_RESULTS_LIMIT",
|
|
72
|
+
"normalize_pagination",
|
|
73
|
+
"paginate",
|
|
74
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""DMN 决策端点(M6-6):定义查询 + 决策求值。
|
|
2
|
+
|
|
3
|
+
DMN 部署走 /deployment/create(含 decision 子元素的 XML 自动分派到 deploy_dmn)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Query, Request
|
|
11
|
+
|
|
12
|
+
from camunda.api.deps import get_engine
|
|
13
|
+
from camunda.api.pagination import DEFAULT_MAX_RESULTS, paginate
|
|
14
|
+
from camunda.api.schemas import EvaluateDecisionDto, decision_definition_dto, from_variable_map
|
|
15
|
+
|
|
16
|
+
router = APIRouter(tags=["decision-definition"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/decision-definition", summary="决策定义列表")
|
|
20
|
+
def list_decision_definitions(
|
|
21
|
+
request: Request,
|
|
22
|
+
firstResult: int = Query(default=0, ge=0, description="分页起点(0 基)"),
|
|
23
|
+
maxResults: int = Query(
|
|
24
|
+
default=DEFAULT_MAX_RESULTS, ge=1, description="分页上限(<=1000)"
|
|
25
|
+
),
|
|
26
|
+
) -> List[Dict[str, Any]]:
|
|
27
|
+
engine = get_engine(request)
|
|
28
|
+
items = [
|
|
29
|
+
decision_definition_dto(d["key"], d["version"], d["name"])
|
|
30
|
+
for d in engine.list_decision_definitions()
|
|
31
|
+
]
|
|
32
|
+
return paginate(items, firstResult, maxResults)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("/decision-definition/key/{key}", summary="按 key 取最新版本的决策定义")
|
|
36
|
+
def get_decision_definition_by_key(request: Request, key: str) -> Dict[str, Any]:
|
|
37
|
+
engine = get_engine(request)
|
|
38
|
+
dec = engine.get_decision_definition(key)
|
|
39
|
+
return decision_definition_dto(key, engine.get_decision_version(key), dec.name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post(
|
|
43
|
+
"/decision-definition/key/{key}/evaluate", summary="求值决策表(返回原始结果)"
|
|
44
|
+
)
|
|
45
|
+
def evaluate_decision(
|
|
46
|
+
request: Request, key: str, body: Optional[EvaluateDecisionDto] = None
|
|
47
|
+
) -> Dict[str, Any]:
|
|
48
|
+
"""求值决策表。
|
|
49
|
+
|
|
50
|
+
返回 `result` 为引擎原始结果形态(单输出列 -> 标量;多输出列 -> dict;
|
|
51
|
+
RULE ORDER/COLLECT -> 列表;无命中 -> None / []),不做 Camunda 的
|
|
52
|
+
DmnDecisionResultEntries 包装(文档化差异)。
|
|
53
|
+
"""
|
|
54
|
+
engine = get_engine(request)
|
|
55
|
+
variables = from_variable_map(body.variables) if body is not None else {}
|
|
56
|
+
result = engine.evaluate_decision(key, variables)
|
|
57
|
+
return {"key": key, "variables": variables, "result": result}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""部署端点(M6-2):POST /deployment/create + GET /deployment。
|
|
2
|
+
|
|
3
|
+
BPMN 与 DMN 共用同一端点:按 XML 根元素的子元素自动分派
|
|
4
|
+
(含 decision -> DMN,含 process -> BPMN;两者根元素都叫 definitions)。
|
|
5
|
+
|
|
6
|
+
对齐 Camunda:multipart/form-data,字段名 `data`,可多文件一次部署。
|
|
7
|
+
本项目额外提供 JSON 便捷通道 `POST /deployment/create/xml`(curl/脚本免构造 multipart)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, File, Query, Request, UploadFile
|
|
15
|
+
from lxml import etree
|
|
16
|
+
|
|
17
|
+
from camunda.api.deps import get_engine
|
|
18
|
+
from camunda.api.pagination import DEFAULT_MAX_RESULTS, paginate
|
|
19
|
+
from camunda.api.schemas import DeploymentDto, decision_definition_dto, process_definition_dto
|
|
20
|
+
from camunda.common.exceptions import DeploymentException, InvalidRequestException
|
|
21
|
+
from camunda.parser import parse_bpmn_xml
|
|
22
|
+
from camunda.parser.dmn_parser import parse_dmn_xml
|
|
23
|
+
|
|
24
|
+
router = APIRouter(tags=["deployment"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _local(tag: str) -> str:
|
|
28
|
+
"""lxml tag 形如 {ns}localName -> localName。"""
|
|
29
|
+
return tag.rsplit("}", 1)[-1]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _classify(xml: str) -> str:
|
|
33
|
+
"""判定 XML 种类:'bpmn' / 'dmn'(无法判定抛 DeploymentException)。"""
|
|
34
|
+
try:
|
|
35
|
+
root = etree.fromstring(xml.encode("utf-8"))
|
|
36
|
+
except etree.XMLSyntaxError as e:
|
|
37
|
+
raise DeploymentException(f"XML 语法错误: {e}") from e
|
|
38
|
+
for child in root:
|
|
39
|
+
if not isinstance(child.tag, str):
|
|
40
|
+
continue
|
|
41
|
+
if _local(child.tag) == "decision":
|
|
42
|
+
return "dmn"
|
|
43
|
+
if _local(child.tag) == "process":
|
|
44
|
+
return "bpmn"
|
|
45
|
+
raise DeploymentException(
|
|
46
|
+
"XML 根元素内未发现 process 或 decision 子元素,无法判定 BPMN / DMN"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _deploy_xml(engine, xml: str, name: Optional[str] = None) -> Dict[str, List[str]]:
|
|
51
|
+
"""部署一份 XML,返回 {"process_keys": [...], "decision_keys": [...]}。"""
|
|
52
|
+
kind = _classify(xml)
|
|
53
|
+
if kind == "bpmn":
|
|
54
|
+
keys = engine.deploy(parse_bpmn_xml(xml, source_name=name), name=name)
|
|
55
|
+
return {"process_keys": list(keys), "decision_keys": []}
|
|
56
|
+
keys = engine.deploy_dmn(parse_dmn_xml(xml, source_name=name), name=name)
|
|
57
|
+
return {"process_keys": [], "decision_keys": list(keys)}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _deployment_body(
|
|
61
|
+
engine, deployments: List[Dict[str, Any]], name: Optional[str] = None
|
|
62
|
+
) -> Dict[str, Any]:
|
|
63
|
+
"""聚合若干次部署记录 -> Camunda DeploymentWithDefinitionsDto 形态。"""
|
|
64
|
+
proc_defs: Dict[str, Any] = {}
|
|
65
|
+
dec_defs: Dict[str, Any] = {}
|
|
66
|
+
dep_id = None
|
|
67
|
+
dep_time = None
|
|
68
|
+
for dep in deployments:
|
|
69
|
+
dep_id = dep["id"]
|
|
70
|
+
dep_time = dep["time"]
|
|
71
|
+
for key in dep["process_keys"]:
|
|
72
|
+
version = engine.get_definition_version(key)
|
|
73
|
+
proc_defs[f"{key}:{version}"] = process_definition_dto(
|
|
74
|
+
key, engine.get_process_definition(key).name, version
|
|
75
|
+
)
|
|
76
|
+
for key in dep["decision_keys"]:
|
|
77
|
+
version = engine.get_decision_version(key)
|
|
78
|
+
dec_defs[f"{key}:{version}"] = decision_definition_dto(
|
|
79
|
+
key, version, engine.get_decision_definition(key).name
|
|
80
|
+
)
|
|
81
|
+
return {
|
|
82
|
+
"id": dep_id,
|
|
83
|
+
"name": name,
|
|
84
|
+
"time": dep_time,
|
|
85
|
+
"deployedProcessDefinitions": proc_defs,
|
|
86
|
+
"deployedDecisionDefinitions": dec_defs,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.post("/deployment/create", summary="部署 BPMN/DMN XML(multipart)")
|
|
91
|
+
async def create_deployment(
|
|
92
|
+
request: Request,
|
|
93
|
+
data: Optional[List[UploadFile]] = File(default=None, alias="data"),
|
|
94
|
+
) -> Dict[str, Any]:
|
|
95
|
+
"""multipart 部署,字段名 `data`,可多文件(对齐 Camunda 7 REST)。"""
|
|
96
|
+
engine = get_engine(request)
|
|
97
|
+
if not data:
|
|
98
|
+
raise InvalidRequestException(
|
|
99
|
+
"缺少上传文件:form-data 字段名须为 data,可重复携带多个 .bpmn / .dmn"
|
|
100
|
+
)
|
|
101
|
+
deployments: List[Dict[str, Any]] = []
|
|
102
|
+
for upload in data:
|
|
103
|
+
content = (await upload.read()).decode("utf-8")
|
|
104
|
+
before = len(engine.list_deployments())
|
|
105
|
+
_deploy_xml(engine, content, name=upload.filename)
|
|
106
|
+
deployments.extend(engine.list_deployments()[before:])
|
|
107
|
+
return _deployment_body(engine, deployments)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.post("/deployment/create/xml", summary="部署 BPMN/DMN XML(JSON 便捷通道)")
|
|
111
|
+
def create_deployment_xml(request: Request, body: DeploymentDto) -> Dict[str, Any]:
|
|
112
|
+
"""JSON 便捷通道(本项目扩展):body = {"xml": "...", "name": "..."}。"""
|
|
113
|
+
engine = get_engine(request)
|
|
114
|
+
before = len(engine.list_deployments())
|
|
115
|
+
_deploy_xml(engine, body.xml, name=body.name)
|
|
116
|
+
return _deployment_body(engine, engine.list_deployments()[before:], name=body.name)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.get("/deployment", summary="部署列表")
|
|
120
|
+
def list_deployments(
|
|
121
|
+
request: Request,
|
|
122
|
+
firstResult: int = Query(default=0, ge=0, description="分页起点(0 基)"),
|
|
123
|
+
maxResults: int = Query(
|
|
124
|
+
default=DEFAULT_MAX_RESULTS, ge=1, description="分页上限(<=1000)"
|
|
125
|
+
),
|
|
126
|
+
) -> List[Dict[str, Any]]:
|
|
127
|
+
engine = get_engine(request)
|
|
128
|
+
items = [
|
|
129
|
+
{
|
|
130
|
+
"id": d["id"],
|
|
131
|
+
"name": d["name"],
|
|
132
|
+
"time": d["time"],
|
|
133
|
+
"source": d["source"],
|
|
134
|
+
"process_keys": list(d["process_keys"]),
|
|
135
|
+
"decision_keys": list(d["decision_keys"]),
|
|
136
|
+
}
|
|
137
|
+
for d in engine.list_deployments()
|
|
138
|
+
]
|
|
139
|
+
return paginate(items, firstResult, maxResults)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""历史端点(M6-5):实例 / 任务 / 活动 / 变量历史。
|
|
2
|
+
|
|
3
|
+
数据源:引擎内存中保留的流程实例(含已结束的——实例完成后仍留在
|
|
4
|
+
`_instances`,仅 state 置 COMPLETED)。启用 Store 时 ACT_HI_* 表同步写入,
|
|
5
|
+
但本端点统一走内存视图,保证两种模式下行为一致(文档化差异:历史查询
|
|
6
|
+
不做跨重启回溯,被 DELETE 删除的实例在内存视图中不再可见)。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Query, Request
|
|
14
|
+
|
|
15
|
+
from camunda.api.deps import get_engine
|
|
16
|
+
from camunda.api.pagination import DEFAULT_MAX_RESULTS, paginate
|
|
17
|
+
from camunda.api.schemas import (
|
|
18
|
+
activity_instance_dto,
|
|
19
|
+
historic_process_instance_dto,
|
|
20
|
+
historic_task_dto,
|
|
21
|
+
to_variable_map,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
router = APIRouter(tags=["history"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/history/process-instance", summary="历史流程实例")
|
|
28
|
+
def list_historic_process_instances(
|
|
29
|
+
request: Request,
|
|
30
|
+
processDefinitionKey: Optional[str] = Query(default=None),
|
|
31
|
+
finished: Optional[bool] = Query(default=None, description="true=只看已结束"),
|
|
32
|
+
businessKey: Optional[str] = Query(default=None),
|
|
33
|
+
firstResult: int = Query(default=0, ge=0, description="分页起点(0 基)"),
|
|
34
|
+
maxResults: int = Query(
|
|
35
|
+
default=DEFAULT_MAX_RESULTS, ge=1, description="分页上限(<=1000)"
|
|
36
|
+
),
|
|
37
|
+
) -> List[Dict[str, Any]]:
|
|
38
|
+
engine = get_engine(request)
|
|
39
|
+
instances = engine.list_process_instances()
|
|
40
|
+
if processDefinitionKey is not None:
|
|
41
|
+
instances = [
|
|
42
|
+
pi for pi in instances if pi.process_definition_key == processDefinitionKey
|
|
43
|
+
]
|
|
44
|
+
if businessKey is not None:
|
|
45
|
+
instances = [pi for pi in instances if pi.business_key == businessKey]
|
|
46
|
+
if finished is not None:
|
|
47
|
+
instances = [pi for pi in instances if pi.is_completed == finished]
|
|
48
|
+
items = [historic_process_instance_dto(pi) for pi in instances]
|
|
49
|
+
return paginate(items, firstResult, maxResults)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/history/process-instance/{instance_id}", summary="单个历史流程实例")
|
|
53
|
+
def get_historic_process_instance(
|
|
54
|
+
request: Request, instance_id: str
|
|
55
|
+
) -> Dict[str, Any]:
|
|
56
|
+
engine = get_engine(request)
|
|
57
|
+
return historic_process_instance_dto(engine.get_process_instance(instance_id))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/history/task", summary="历史任务(已归档 + 待办)")
|
|
61
|
+
def list_historic_tasks(
|
|
62
|
+
request: Request,
|
|
63
|
+
processInstanceId: Optional[str] = Query(default=None),
|
|
64
|
+
finished: Optional[bool] = Query(default=None, description="true=只看已完成"),
|
|
65
|
+
firstResult: int = Query(default=0, ge=0, description="分页起点(0 基)"),
|
|
66
|
+
maxResults: int = Query(
|
|
67
|
+
default=DEFAULT_MAX_RESULTS, ge=1, description="分页上限(<=1000)"
|
|
68
|
+
),
|
|
69
|
+
) -> List[Dict[str, Any]]:
|
|
70
|
+
engine = get_engine(request)
|
|
71
|
+
out: List[Dict[str, Any]] = []
|
|
72
|
+
instances = engine.list_process_instances()
|
|
73
|
+
if processInstanceId is not None:
|
|
74
|
+
instances = [pi for pi in instances if pi.id == processInstanceId]
|
|
75
|
+
for pi in instances:
|
|
76
|
+
if finished is not True:
|
|
77
|
+
out.extend(historic_task_dto(t) for t in pi.completed_tasks)
|
|
78
|
+
if finished is not False:
|
|
79
|
+
# 待办任务(end_time 为空)也纳入历史视图,对齐 Camunda HistoricTaskInstance
|
|
80
|
+
out.extend(
|
|
81
|
+
historic_task_dto(t)
|
|
82
|
+
for t in engine.create_task_query(process_instance_id=pi.id)
|
|
83
|
+
)
|
|
84
|
+
return paginate(out, firstResult, maxResults)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.get("/history/activity-instance", summary="历史活动实例")
|
|
88
|
+
def list_historic_activity_instances(
|
|
89
|
+
request: Request,
|
|
90
|
+
processInstanceId: Optional[str] = Query(default=None),
|
|
91
|
+
firstResult: int = Query(default=0, ge=0, description="分页起点(0 基)"),
|
|
92
|
+
maxResults: int = Query(
|
|
93
|
+
default=DEFAULT_MAX_RESULTS, ge=1, description="分页上限(<=1000)"
|
|
94
|
+
),
|
|
95
|
+
) -> List[Dict[str, Any]]:
|
|
96
|
+
engine = get_engine(request)
|
|
97
|
+
instances = engine.list_process_instances()
|
|
98
|
+
if processInstanceId is not None:
|
|
99
|
+
instances = [pi for pi in instances if pi.id == processInstanceId]
|
|
100
|
+
items = [activity_instance_dto(ai) for pi in instances for ai in pi.activity_history]
|
|
101
|
+
return paginate(items, firstResult, maxResults)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.get("/history/variable-instance", summary="历史变量(实例级快照)")
|
|
105
|
+
def list_historic_variable_instances(
|
|
106
|
+
request: Request,
|
|
107
|
+
processInstanceId: Optional[str] = Query(default=None),
|
|
108
|
+
bare: bool = Query(default=False),
|
|
109
|
+
firstResult: int = Query(default=0, ge=0, description="分页起点(0 基)"),
|
|
110
|
+
maxResults: int = Query(
|
|
111
|
+
default=DEFAULT_MAX_RESULTS, ge=1, description="分页上限(<=1000)"
|
|
112
|
+
),
|
|
113
|
+
) -> List[Dict[str, Any]]:
|
|
114
|
+
"""变量历史:本项目为实例级快照语义(非每次变更追加版本,文档化差异)。"""
|
|
115
|
+
engine = get_engine(request)
|
|
116
|
+
instances = engine.list_process_instances()
|
|
117
|
+
if processInstanceId is not None:
|
|
118
|
+
instances = [pi for pi in instances if pi.id == processInstanceId]
|
|
119
|
+
out: List[Dict[str, Any]] = []
|
|
120
|
+
for pi in instances:
|
|
121
|
+
for name, dto in to_variable_map(pi.variables, bare=bare).items():
|
|
122
|
+
if bare:
|
|
123
|
+
out.append(
|
|
124
|
+
{"processInstanceId": pi.id, "name": name, "value": dto}
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
out.append({"processInstanceId": pi.id, "name": name, **dto})
|
|
128
|
+
return paginate(out, firstResult, maxResults)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""流程定义端点(M6-2):GET /process-definition。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Query, Request
|
|
8
|
+
|
|
9
|
+
from camunda.api.deps import get_engine
|
|
10
|
+
from camunda.api.pagination import DEFAULT_MAX_RESULTS, paginate
|
|
11
|
+
from camunda.api.schemas import process_definition_dto
|
|
12
|
+
|
|
13
|
+
router = APIRouter(tags=["process-definition"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/process-definition", summary="流程定义列表")
|
|
17
|
+
def list_process_definitions(
|
|
18
|
+
request: Request,
|
|
19
|
+
firstResult: int = Query(default=0, ge=0, description="分页起点(0 基)"),
|
|
20
|
+
maxResults: int = Query(
|
|
21
|
+
default=DEFAULT_MAX_RESULTS, ge=1, description="分页上限(<=1000)"
|
|
22
|
+
),
|
|
23
|
+
) -> List[Dict[str, Any]]:
|
|
24
|
+
engine = get_engine(request)
|
|
25
|
+
items = [
|
|
26
|
+
process_definition_dto(d["key"], d["name"], d["version"])
|
|
27
|
+
for d in engine.list_process_definitions()
|
|
28
|
+
]
|
|
29
|
+
return paginate(items, firstResult, maxResults)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/process-definition/key/{key}", summary="按 key 取最新版本的流程定义")
|
|
33
|
+
def get_process_definition_by_key(request: Request, key: str) -> Dict[str, Any]:
|
|
34
|
+
engine = get_engine(request)
|
|
35
|
+
proc = engine.get_process_definition(key)
|
|
36
|
+
return process_definition_dto(key, proc.name, engine.get_definition_version(key))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/process-definition/key/{key}/xml", summary="取流程定义 XML")
|
|
40
|
+
def get_process_definition_xml(request: Request, key: str) -> Dict[str, Any]:
|
|
41
|
+
engine = get_engine(request)
|
|
42
|
+
version = engine.get_definition_version(key)
|
|
43
|
+
return {
|
|
44
|
+
"id": f"{key}:{version}",
|
|
45
|
+
"key": key,
|
|
46
|
+
"version": version,
|
|
47
|
+
# 部署时未带 source_xml(如直接 deploy(BpmnModel) 构造)则为 None
|
|
48
|
+
"bpmn20Xml": engine.get_process_definition_xml(key),
|
|
49
|
+
}
|