Files
dataease/scripts/engine.py
T
2026-08-09 00:36:02 +00:00

327 lines
14 KiB
Python

import json, re, time, random, os, sys, requests
import urllib3
urllib3.disable_warnings()
# Local SDK client
from client import DataEaseClient
class DataEaseChartEngine:
def __init__(self, base_url, ak_or_user, sk_or_password, auth_mode=None):
self.base_url = base_url.rstrip('/')
self.api_prefix = os.environ.get('DATAEASE_API_PREFIX', '/de2api')
self.auth_mode = auth_mode or 'aksk'
if self.auth_mode == 'password':
import importlib.util
spec = importlib.util.spec_from_file_location("inspect_data", os.path.join(os.path.dirname(__file__), "inspect_data.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
x_de_token = mod.login_with_password(base_url, self.api_prefix, ak_or_user, sk_or_password)
self.headers = mod.get_headers_token(x_de_token)
self.client = None
else:
self.client = DataEaseClient(base_url, ak_or_user, sk_or_password)
self.headers = None
def _get_headers(self):
if self.headers:
return self.headers
return self._get_headers()
def _post(self, path, payload=None):
url = f"{self.base_url}{self.api_prefix}{path}"
return requests.post(url, headers=self._get_headers(), json=payload, verify=False, timeout=30)
def _get(self, path, params=None):
url = f"{self.base_url}{self.api_prefix}{path}"
return requests.get(url, headers=self._get_headers(), params=params, verify=False, timeout=30)
def resolve_dataset_id(self, name_or_id):
"""Resolve dataset name to ID using /datasetTree/tree if needed"""
if str(name_or_id).isdigit() and len(str(name_or_id)) > 10:
return name_or_id
url = f"{self.base_url}{self.api_prefix}/datasetTree/tree"
headers = self._get_headers()
resp = requests.post(url, headers=headers, json={"busiFlag": "dataset"}, verify=False, timeout=30)
if resp.status_code != 200:
raise Exception(f"Failed to fetch dataset tree: {resp.text}")
nodes = resp.json().get('data', [])
def find_in_tree(items, target_name):
for item in items:
if item.get('name') == target_name:
return item.get('id')
children = item.get('children', [])
if children:
found = find_in_tree(children, target_name)
if found: return found
return None
dataset_id = find_in_tree(nodes, name_or_id)
if not dataset_id:
raise ValueError(f"Dataset '{name_or_id}' not found in DataEase")
return dataset_id
def get_dataset_ctx(self, dataset_name_or_id, x_names, y_names):
"""Fetch dataset metadata and build rendering context"""
dataset_id = self.resolve_dataset_id(dataset_name_or_id)
# DataEase v2 often uses datasetGroup for the tree structure
url = f"{self.base_url}{self.api_prefix}/datasetTree/details/{dataset_id}"
headers = self._get_headers()
resp = requests.get(url, headers=headers, verify=False, timeout=30)
if resp.status_code != 200:
# Fallback to the original method if the specific tree detail endpoint fails
resp = self._post(f"/datasetField/listByDatasetGroup/{dataset_id}")
fields = resp.json().get('data', [])
else:
data = resp.json().get('data', {})
fields = data.get('allFields', [])
if not fields:
# Try to get from datasetField/listByDatasetGroup if tree details is empty
resp = self._post(f"/datasetField/listByDatasetGroup/{dataset_id}")
fields = resp.json().get('data', [])
if not fields:
raise ValueError(f"No fields found for dataset {dataset_id}")
f_map = {f['name']: f for f in fields}
# Get table/datasource IDs from the first field (they should be common)
datasource_id = fields[0].get('datasourceId', "")
table_id = fields[0].get('datasetTableId', "")
ctx = {
"DATASET_GROUP_ID": dataset_id,
"DATASOURCE_ID": datasource_id,
"DATASET_TABLE_ID": table_id,
}
# Map axis fields (supports multi-measure if needed)
for i, name in enumerate(x_names):
suffix = "" if i == 0 else str(i+1)
f = f_map.get(name)
if not f: raise ValueError(f"X-Axis Field '{name}' not found")
ctx[f"XAXIS{suffix}_FIELD_ID"] = f['id']
ctx[f"XAXIS{suffix}_DE_NAME"] = f['dataeaseName']
for i, name in enumerate(y_names):
suffix = "" if i == 0 else str(i+1)
f = f_map.get(name)
if not f: raise ValueError(f"Y-Axis Field '{name}' not found")
ctx[f"YAXIS{suffix}_FIELD_ID"] = f['id']
ctx[f"YAXIS{suffix}_DE_NAME"] = f['dataeaseName']
ctx[f"YAXIS{suffix}_SERIES_ID"] = f"{f['id']}-yAxis"
# Additional keys for deep parameterization
if i == 0:
ctx["YAXIS_FIELD_ID"] = f['id']
ctx["YAXIS_DE_NAME"] = f['dataeaseName']
ctx["YAXIS_SERIES_ID"] = f"{f['id']}-yAxis"
elif i == 1:
ctx["YAXIS2_FIELD_ID"] = f['id']
ctx["YAXIS2_DE_NAME"] = f['dataeaseName']
ctx["YAXIS2_SERIES_ID"] = f"{f['id']}-yAxis"
return ctx
def deploy(self, chart_type, title, dataset_id, x_names, y_names, layout=None):
# 1. Directory standardization
# Use relative path from this script (scripts/engine.py) to templates/
base_dir = os.path.dirname(os.path.abspath(__file__))
tpl_dir = os.path.join(base_dir, "..", "templates", f"chart_{chart_type}")
if not os.path.exists(tpl_dir):
raise FileNotFoundError(f"Template directory {tpl_dir} not found")
with open(os.path.join(tpl_dir, "template.j2")) as f: template_str = f.read()
with open(os.path.join(tpl_dir, "params.json")) as f:
raw_params = json.load(f)
# 2. Build Context (Flatten params + dynamic IDs)
ctx = {}
# Flatten nested params.json
def flatten_dict(d):
items = {}
for k, v in d.items():
if isinstance(v, dict):
items.update(flatten_dict(v))
elif not k.startswith("_"):
items[k] = v
return items
ctx.update(flatten_dict(raw_params))
ctx.update(self.get_dataset_ctx(dataset_id, x_names, y_names))
# 3. Runtime Randomization
view_id, content_id = self.rand_id(), self.rand_id()
ctx.update({"VIEW_ID": view_id, "CONTENT_ID": content_id, "SCENE_ID": "0"})
# 4. Thorough Parameterization (Global substitution)
def rep(m):
k = m.group(1).strip()
if k in ctx:
return str(ctx[k])
return m.group(0) # Keep placeholder if not found
rendered = re.sub(r"\{\{\s*(\w+)\s*\}\}", rep, template_str)
payload = json.loads(rendered)
# 5. Type Closure & Payload Finalization
board_name = f"{title}_{int(time.time())}"
# Override title in canvasViewInfo
if "canvasViewInfo" in payload and view_id in payload["canvasViewInfo"]:
view_info = payload["canvasViewInfo"][view_id]
view_info["title"] = title
# Deep update all field names (xAxis, yAxis, labels, tooltips, etc.)
def deep_update_field_names(obj, target_id, new_name):
if isinstance(obj, dict):
if str(obj.get("id")) == str(target_id):
obj["name"] = new_name
if "description" in obj: obj["description"] = new_name
if "originName" in obj: obj["originName"] = new_name
# 核心修复:DataEase V2 在某些场景下会回退到 dbFieldName,强制设为 null 或同步
if "dbFieldName" in obj: obj["dbFieldName"] = None
# 特殊修复:处理 DataEase 特有的标签和提示框拼接字段 (如 "访问平台(求和)")
# 我们无法预知原始模板里的名字,所以这里尝试匹配常见的拼接模式
if "optionLabel" in obj:
obj["optionLabel"] = new_name + (obj["optionLabel"].partition("(")[1] + obj["optionLabel"].partition("(")[2] if "(" in obj["optionLabel"] else "")
if "optionShowName" in obj:
obj["optionShowName"] = new_name + (obj["optionShowName"].partition("(")[1] + obj["optionShowName"].partition("(")[2] if "(" in obj["optionShowName"] else "")
for v in obj.values():
deep_update_field_names(v, target_id, new_name)
elif isinstance(obj, list):
for item in obj:
deep_update_field_names(item, target_id, new_name)
# Apply updates for X and Y axis fields
# We use the field IDs obtained from dataset metadata to find and replace
dataset_ctx = self.get_dataset_ctx(dataset_id, x_names, y_names)
# Pre-scan templates/params.json for the old names to ensure replacement works
# or use common default names from templates
old_names_to_clear = ["访问次数", "访问平台", "浏览量"]
for i, x_name in enumerate(x_names):
f_id = dataset_ctx.get(f"XAXIS{'' if i==0 else i+1}_FIELD_ID")
if f_id:
deep_update_field_names(view_info, f_id, x_name)
for i, y_name in enumerate(y_names):
f_id = dataset_ctx.get(f"YAXIS{'' if i==0 else i+1}_FIELD_ID")
if f_id:
deep_update_field_names(view_info, f_id, y_name)
# 最终保底:如果还有残留的“访问次数”或“访问平台”,强行全局替换
def brute_force_replace(obj, search_list, replace_list):
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, str):
for s, r in zip(search_list, replace_list):
if s in v:
obj[k] = v.replace(s, r)
else:
brute_force_replace(v, search_list, replace_list)
elif isinstance(obj, list):
for item in obj:
brute_force_replace(item, search_list, replace_list)
# Use primary X/Y names for brute force fallback
if x_names and y_names:
brute_force_replace(view_info, ["访问平台", "访问次数", "浏览量"], [x_names[0], y_names[0], y_names[0]])
# If layout is provided, override it in componentData
if "componentData" in payload:
components = json.loads(payload["componentData"])
if components:
target = components[0]
# Update component name to title
target["name"] = title
target["label"] = title
# 单图表模式:充满整个画布
if layout is None and len(components) == 1:
# DataEase 单图表网格 72x36,让图表充满整个画布
target["x"] = 1
target["y"] = 1
target["sizeX"] = 72
target["sizeY"] = 36
if "style" not in target:
target["style"] = {}
target["style"]["width"] = 1920
target["style"]["height"] = 1080
target["style"]["left"] = 0
target["style"]["top"] = 0
elif layout:
if "x" in layout: target["x"] = layout["x"]
if "y" in layout: target["y"] = layout["y"]
if "sizeX" in layout: target["sizeX"] = layout["sizeX"]
if "sizeY" in layout: target["sizeY"] = layout["sizeY"]
if "style" not in target: target["style"] = {}
if "width" in layout: target["style"]["width"] = layout["width"]
if "height" in layout: target["style"]["height"] = layout["height"]
if "left" in layout: target["style"]["left"] = layout["left"]
if "top" in layout: target["style"]["top"] = layout["top"]
payload["componentData"] = json.dumps(components, separators=(',', ':'), ensure_ascii=False)
# Ensure canvasStyleData is also compact
if "canvasStyleData" in payload:
style = json.loads(payload["canvasStyleData"])
payload["canvasStyleData"] = json.dumps(style, separators=(',', ':'), ensure_ascii=False)
payload.update({
"id": None,
"name": board_name,
"type": "dashboard",
"status": 0,
"dataState": "ready",
"selfWatermarkStatus": True,
"checkVersion": "2.10.20",
"pid": "0",
"mobileLayout": False
})
headers = self._get_headers()
print(f"Deploying chart '{title}' (Type: {chart_type})...")
save_r = requests.post(
f"{self.base_url}{self.api_prefix}/dataVisualization/saveCanvas",
headers=headers, json=payload, verify=False, timeout=30
)
if save_r.status_code != 200:
raise Exception(f"saveCanvas failed: {save_r.text}")
dashboard_id = str(save_r.json()["data"])
# 6. Publish
requests.post(
f"{self.base_url}{self.api_prefix}/dataVisualization/updatePublishStatus",
headers=headers, verify=False, timeout=30,
json={
"id": dashboard_id,
"name": board_name,
"activeViewIds": [view_id],
"status": 1,
"type": "dashboard",
"mobileLayout": False
}
)
# Generate preview URL
return dashboard_id, f"{self.base_url}/#/preview?dvId={dashboard_id}&dvType=dashboard&ignoreParams=true"
@staticmethod
def rand_id():
return str(int(time.time() * 1000) + random.randint(1000, 9999))