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))