@josephyan/qingflow-cli 0.2.0-beta.70 → 0.2.0-beta.72

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.
@@ -0,0 +1,399 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from ..errors import QingflowApiError, raise_tool_error
7
+ from ..json_types import JSONObject
8
+ from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS
9
+ from .app_tools import _analysis_supported_for_view_type
10
+ from .app_tools import AppTools
11
+ from .base import ToolBase
12
+ from .qingbi_report_tools import QingbiReportTools
13
+
14
+
15
+ class ResourceReadTools(ToolBase):
16
+ def __init__(self, sessions, backend) -> None:
17
+ super().__init__(sessions, backend)
18
+ self.apps = AppTools(sessions, backend)
19
+ self.charts = QingbiReportTools(sessions, backend)
20
+
21
+ def portal_list(self, *, profile: str) -> JSONObject:
22
+ def runner(session_profile, context):
23
+ raw_items = self.backend.request("GET", context, "/dash")
24
+ items = _normalize_portal_list_items(raw_items)
25
+ return {
26
+ "profile": profile,
27
+ "ws_id": session_profile.selected_ws_id,
28
+ "ok": True,
29
+ "warnings": [],
30
+ "verification": {"portal_list_loaded": True},
31
+ "data": {
32
+ "items": items,
33
+ "total": len(items),
34
+ },
35
+ }
36
+
37
+ return self._run(profile, runner)
38
+
39
+ def portal_get(self, *, profile: str, dash_key: str) -> JSONObject:
40
+ self._require_dash_key(dash_key)
41
+
42
+ def runner(session_profile, context):
43
+ result = self.backend.request("GET", context, f"/dash/{dash_key}", params={"beingDraft": False})
44
+ dash_name = str(result.get("dashName") or "").strip() or None
45
+ dash_icon = str(result.get("dashIcon") or "").strip() or None
46
+ package_tag_ids = [
47
+ tag_id
48
+ for tag_id in (
49
+ _coerce_positive_int((tag or {}).get("tagId"))
50
+ for tag in (result.get("tags") or [])
51
+ if isinstance(tag, dict)
52
+ )
53
+ if tag_id is not None
54
+ ]
55
+ components = _normalize_user_portal_components(result.get("components"))
56
+ return {
57
+ "profile": profile,
58
+ "ws_id": session_profile.selected_ws_id,
59
+ "ok": True,
60
+ "warnings": [],
61
+ "verification": {"portal_exists": True},
62
+ "data": {
63
+ "dash_key": dash_key,
64
+ "dash_name": dash_name,
65
+ "dash_icon": dash_icon,
66
+ "package_tag_ids": package_tag_ids,
67
+ "component_count": len(components),
68
+ "components": components,
69
+ },
70
+ }
71
+
72
+ return self._run(profile, runner)
73
+
74
+ def view_get(self, *, profile: str, view_id: str) -> JSONObject:
75
+ self._require_view_id(view_id)
76
+ view_key = _extract_custom_view_key(view_id)
77
+ system_view = _lookup_system_view_descriptor(view_id)
78
+ if view_key is None and system_view is None:
79
+ raise_tool_error(
80
+ QingflowApiError.config_error(
81
+ "view_get only accepts accessible view_id values such as `custom:VIEW_KEY` or `system:all`; use app_get or portal_get first to find a valid view_id."
82
+ )
83
+ )
84
+ if system_view is not None:
85
+ return self._run(
86
+ profile,
87
+ lambda session_profile, _context: {
88
+ "profile": profile,
89
+ "ws_id": session_profile.selected_ws_id,
90
+ "ok": True,
91
+ "warnings": [
92
+ {
93
+ "code": "VIEW_APP_KEY_UNRESOLVED",
94
+ "message": f"view_get could not resolve app_key for system view `{view_id}`; keep using the app_key from the parent app context.",
95
+ }
96
+ ],
97
+ "verification": {
98
+ "view_exists": True,
99
+ "descriptor_only": True,
100
+ },
101
+ "data": {
102
+ "app_key": None,
103
+ "view_id": view_id,
104
+ "view_key": None,
105
+ "view_name": system_view["view_name"],
106
+ "view_type": system_view["view_type"],
107
+ "visible_columns": [],
108
+ "analysis_supported": system_view["analysis_supported"],
109
+ },
110
+ },
111
+ )
112
+
113
+ def runner(session_profile, context):
114
+ warnings: list[JSONObject] = []
115
+ verification = {
116
+ "view_exists": True,
117
+ "questions_verified": True,
118
+ }
119
+ config = self.backend.request("GET", context, f"/view/{view_key}/viewConfig")
120
+ base_info = self.backend.request("GET", context, f"/view/{view_key}/viewConfig/baseInfo")
121
+ questions: list[dict[str, Any]] = []
122
+ try:
123
+ questions_payload = self.backend.request("GET", context, f"/view/{view_key}/question")
124
+ if isinstance(questions_payload, list):
125
+ questions = [deepcopy(item) for item in questions_payload if isinstance(item, dict)]
126
+ except QingflowApiError:
127
+ verification["questions_verified"] = False
128
+ warnings.append(
129
+ {
130
+ "code": "VIEW_QUESTIONS_UNAVAILABLE",
131
+ "message": "view_get could not load visible columns because question readback is unavailable.",
132
+ }
133
+ )
134
+
135
+ raw_view_type = (
136
+ str(base_info.get("viewgraphType") or "").strip()
137
+ or str(config.get("viewgraphType") or config.get("viewType") or "").strip()
138
+ )
139
+ resolved_app_key = str(base_info.get("appKey") or config.get("appKey") or "").strip() or None
140
+ if not resolved_app_key:
141
+ resolved_app_key = self._resolve_app_key_from_form_id(
142
+ profile=profile,
143
+ form_id=_coerce_positive_int(base_info.get("formId") or config.get("formId")),
144
+ )
145
+ if not resolved_app_key:
146
+ warnings.append(
147
+ {
148
+ "code": "VIEW_APP_KEY_UNRESOLVED",
149
+ "message": f"view_get could not resolve app_key for `{view_id}` from view metadata; keep using the app_key from the parent app or portal context.",
150
+ }
151
+ )
152
+ return {
153
+ "profile": profile,
154
+ "ws_id": session_profile.selected_ws_id,
155
+ "ok": True,
156
+ "warnings": warnings,
157
+ "verification": verification,
158
+ "data": {
159
+ "app_key": resolved_app_key,
160
+ "view_id": view_id,
161
+ "view_key": view_key,
162
+ "view_name": str(
163
+ base_info.get("viewgraphName") or config.get("viewgraphName") or config.get("viewName") or view_key
164
+ ).strip()
165
+ or view_key,
166
+ "view_type": _normalize_view_type(raw_view_type),
167
+ "visible_columns": [
168
+ str(item.get("queTitle") or item.get("title") or "").strip()
169
+ for item in questions
170
+ if str(item.get("queTitle") or item.get("title") or "").strip()
171
+ ],
172
+ "analysis_supported": _analysis_supported_for_view_type(raw_view_type or None),
173
+ },
174
+ }
175
+
176
+ return self._run(profile, runner)
177
+
178
+ def chart_get(self, *, profile: str, chart_id: str) -> JSONObject:
179
+ self._require_chart_id(chart_id)
180
+
181
+ def runner(session_profile, _context):
182
+ base = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
183
+ data = self.charts.qingbi_report_get_data(profile=profile, chart_id=chart_id, payload={}).get("result") or {}
184
+ data_config = data.get("config") if isinstance(data, dict) and isinstance(data.get("config"), dict) else {}
185
+ return {
186
+ "profile": profile,
187
+ "ws_id": session_profile.selected_ws_id,
188
+ "ok": True,
189
+ "warnings": [],
190
+ "verification": {
191
+ "chart_exists": True,
192
+ "chart_data_loaded": True,
193
+ },
194
+ "data": {
195
+ "chart_id": chart_id,
196
+ "chart_name": str(base.get("chartName") or base.get("name") or chart_id).strip() or chart_id,
197
+ "chart_type": str(base.get("chartType") or data_config.get("chartType") or "").strip() or None,
198
+ "data_source_type": str(base.get("dataSourceType") or "").strip() or None,
199
+ "data_source_id": str(base.get("dataSourceId") or "").strip() or None,
200
+ "data": deepcopy(data) if isinstance(data, dict) else {"value": data},
201
+ },
202
+ }
203
+
204
+ return self._run(profile, runner)
205
+
206
+ def _require_dash_key(self, dash_key: str) -> None:
207
+ if not dash_key:
208
+ raise_tool_error(QingflowApiError.config_error("dash_key is required"))
209
+
210
+ def _require_view_id(self, view_id: str) -> None:
211
+ if not view_id:
212
+ raise_tool_error(QingflowApiError.config_error("view_id is required"))
213
+
214
+ def _require_chart_id(self, chart_id: str) -> None:
215
+ if not chart_id:
216
+ raise_tool_error(QingflowApiError.config_error("chart_id is required"))
217
+
218
+ def _resolve_app_key_from_form_id(self, *, profile: str, form_id: int | None) -> str | None:
219
+ if form_id is None:
220
+ return None
221
+ try:
222
+ payload = self.apps.app_list(profile=profile)
223
+ except QingflowApiError:
224
+ return None
225
+ items = payload.get("items")
226
+ if not isinstance(items, list):
227
+ return None
228
+ for item in items:
229
+ if not isinstance(item, dict):
230
+ continue
231
+ if _coerce_positive_int(item.get("form_id")) != form_id:
232
+ continue
233
+ app_key = str(item.get("app_key") or "").strip()
234
+ if app_key:
235
+ return app_key
236
+ return None
237
+
238
+
239
+ def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
240
+ if not isinstance(raw_items, list):
241
+ return []
242
+ items: list[dict[str, Any]] = []
243
+ for item in raw_items:
244
+ if not isinstance(item, dict):
245
+ continue
246
+ dash_key = str(item.get("dashKey") or "").strip()
247
+ dash_name = str(item.get("dashName") or "").strip()
248
+ dash_icon = str(item.get("dashIcon") or "").strip() or None
249
+ package_tag_ids = [
250
+ tag_id
251
+ for tag_id in (
252
+ _coerce_positive_int((tag or {}).get("tagId"))
253
+ for tag in (item.get("tags") or [])
254
+ if isinstance(tag, dict)
255
+ )
256
+ if tag_id is not None
257
+ ]
258
+ if not any((dash_key, dash_name, dash_icon, package_tag_ids)):
259
+ continue
260
+ items.append(
261
+ {
262
+ "dash_key": dash_key or None,
263
+ "dash_name": dash_name or None,
264
+ "dash_icon": dash_icon,
265
+ "package_tag_ids": package_tag_ids,
266
+ }
267
+ )
268
+ return items
269
+
270
+
271
+ def _normalize_user_portal_components(components: Any) -> list[dict[str, Any]]:
272
+ if not isinstance(components, list):
273
+ return []
274
+ items: list[dict[str, Any]] = []
275
+ config_key_map = {
276
+ "grid": "gridConfig",
277
+ "link": "linkConfig",
278
+ "text": "textConfig",
279
+ "filter": "filterConfig",
280
+ "chart": "chartConfig",
281
+ "view": "viewgraphConfig",
282
+ }
283
+ for index, component in enumerate(components):
284
+ if not isinstance(component, dict):
285
+ continue
286
+ source_type = _normalize_portal_component_source_type(component.get("type"))
287
+ title = _extract_portal_component_title(component, source_type=source_type)
288
+ summary: dict[str, Any] = {
289
+ "order": index,
290
+ "source_type": source_type,
291
+ "title": title,
292
+ }
293
+ position = component.get("position")
294
+ if isinstance(position, dict):
295
+ summary["position"] = deepcopy(position)
296
+ config_key = config_key_map.get(source_type, "")
297
+ config = component.get(config_key) if isinstance(component.get(config_key), dict) else {}
298
+ if source_type == "chart":
299
+ summary["chart_ref"] = {
300
+ "chart_id": str(config.get("biChartId") or "").strip() or None,
301
+ "chart_name": str(config.get("chartComponentTitle") or title or "").strip() or None,
302
+ }
303
+ elif source_type == "view":
304
+ view_key = str(config.get("viewgraphKey") or "").strip() or None
305
+ summary["view_ref"] = {
306
+ "app_key": str(config.get("appKey") or "").strip() or None,
307
+ "view_id": f"custom:{view_key}" if view_key else None,
308
+ "view_key": view_key,
309
+ "view_name": str(config.get("viewgraphName") or title or "").strip() or None,
310
+ }
311
+ elif source_type in {"grid", "link", "text", "filter"} and config:
312
+ summary["config"] = deepcopy(config)
313
+ items.append(summary)
314
+ return items
315
+
316
+
317
+ def _normalize_portal_component_source_type(value: Any) -> str:
318
+ raw = str(value or "").strip()
319
+ mapping = {
320
+ "2": "grid",
321
+ "4": "link",
322
+ "5": "text",
323
+ "6": "filter",
324
+ "9": "chart",
325
+ "10": "view",
326
+ "grid": "grid",
327
+ "link": "link",
328
+ "text": "text",
329
+ "filter": "filter",
330
+ "chart": "chart",
331
+ "bi": "chart",
332
+ "view": "view",
333
+ "viewgraph": "view",
334
+ }
335
+ return mapping.get(raw, raw.lower() or "unknown")
336
+
337
+
338
+ def _extract_portal_component_title(component: dict[str, Any], *, source_type: str) -> str | None:
339
+ config_key_map = {
340
+ "grid": "gridConfig",
341
+ "link": "linkConfig",
342
+ "text": "textConfig",
343
+ "filter": "filterConfig",
344
+ "chart": "chartConfig",
345
+ "view": "viewgraphConfig",
346
+ }
347
+ config_key = config_key_map.get(source_type, "")
348
+ config = component.get(config_key) if isinstance(component.get(config_key), dict) else {}
349
+ title_candidates = {
350
+ "grid": ["gridTitle"],
351
+ "link": ["title", "linkTitle"],
352
+ "text": ["title", "textTitle"],
353
+ "filter": ["title", "filterTitle"],
354
+ "chart": ["chartComponentTitle", "componentTitle", "title"],
355
+ "view": ["componentTitle", "viewgraphName", "title"],
356
+ }
357
+ for key in title_candidates.get(source_type, []):
358
+ title = str(config.get(key) or "").strip()
359
+ if title:
360
+ return title
361
+ return None
362
+
363
+
364
+ def _extract_custom_view_key(view_id: str) -> str | None:
365
+ value = str(view_id or "").strip()
366
+ if not value.startswith("custom:"):
367
+ return None
368
+ view_key = value.split(":", 1)[1].strip()
369
+ return view_key or None
370
+
371
+
372
+ def _lookup_system_view_descriptor(view_id: str) -> dict[str, Any] | None:
373
+ normalized = str(view_id or "").strip()
374
+ for system_view_id, _list_type, name in SYSTEM_VIEW_DEFINITIONS:
375
+ if system_view_id != normalized:
376
+ continue
377
+ return {
378
+ "view_name": name,
379
+ "view_type": "system",
380
+ "analysis_supported": True,
381
+ }
382
+ return None
383
+
384
+
385
+ def _normalize_view_type(view_type: Any) -> str | None:
386
+ value = str(view_type or "").strip()
387
+ if not value:
388
+ return None
389
+ if value.lower().endswith("view"):
390
+ value = value[:-4]
391
+ return value or None
392
+
393
+
394
+ def _coerce_positive_int(value: Any) -> int | None:
395
+ try:
396
+ number = int(value)
397
+ except (TypeError, ValueError):
398
+ return None
399
+ return number if number > 0 else None