@josephyan/qingflow-cli 0.2.0-beta.999 → 1.0.6
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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +109 -0
- package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
- package/src/qingflow_mcp/builder_facade/models.py +44 -5
- package/src/qingflow_mcp/builder_facade/service.py +21 -8
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/app.py +47 -1
- package/src/qingflow_mcp/cli/commands/builder.py +7 -0
- package/src/qingflow_mcp/cli/commands/exports.py +111 -0
- package/src/qingflow_mcp/cli/commands/record.py +20 -0
- package/src/qingflow_mcp/cli/commands/task.py +644 -22
- package/src/qingflow_mcp/cli/commands/workspace.py +49 -50
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +139 -4
- package/src/qingflow_mcp/cli/interaction.py +72 -0
- package/src/qingflow_mcp/cli/main.py +2 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +55 -9
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/public_surface.py +6 -0
- package/src/qingflow_mcp/response_trim.py +40 -1
- package/src/qingflow_mcp/server.py +22 -0
- package/src/qingflow_mcp/server_app_builder.py +4 -0
- package/src/qingflow_mcp/server_app_user.py +104 -8
- package/src/qingflow_mcp/session_store.py +57 -6
- package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
- package/src/qingflow_mcp/tools/auth_tools.py +26 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
- package/src/qingflow_mcp/tools/export_tools.py +1565 -0
- package/src/qingflow_mcp/tools/import_tools.py +42 -2
- package/src/qingflow_mcp/tools/record_tools.py +515 -45
- package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
|
@@ -0,0 +1,1565 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from time import monotonic, sleep
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
from ..backend_client import BackendRequestContext
|
|
15
|
+
from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
|
|
16
|
+
from ..errors import QingflowApiError
|
|
17
|
+
from ..export_store import ExportJobStore
|
|
18
|
+
from ..json_types import JSONObject
|
|
19
|
+
from .base import ToolBase, tool_cn_name
|
|
20
|
+
from .record_tools import (
|
|
21
|
+
AccessibleViewRoute,
|
|
22
|
+
DEFAULT_LIST_PAGE_SIZE,
|
|
23
|
+
FormField,
|
|
24
|
+
LAYOUT_ONLY_QUE_TYPES,
|
|
25
|
+
RecordTools,
|
|
26
|
+
_normalize_public_column_selectors,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
EXPORT_STATUS_BY_PROCESS_STATUS = {
|
|
31
|
+
1: "queued",
|
|
32
|
+
2: "running",
|
|
33
|
+
3: "succeeded",
|
|
34
|
+
4: "failed",
|
|
35
|
+
5: "failed",
|
|
36
|
+
}
|
|
37
|
+
DEFAULT_EXPORT_TIMEOUT_SECONDS = 60.0
|
|
38
|
+
EXPORT_POLL_INTERVAL_SECONDS = 1.5
|
|
39
|
+
EXPORT_ROWS_LIMIT = 20_000
|
|
40
|
+
EXPORT_CELL_LIMIT = 4_000_000
|
|
41
|
+
_SAFE_FILE_CHARS = re.compile(r"[^0-9A-Za-z._-]+")
|
|
42
|
+
LOCAL_TIMEZONE = datetime.now().astimezone().tzinfo or timezone.utc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ExportTools(ToolBase):
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
sessions,
|
|
49
|
+
backend,
|
|
50
|
+
*,
|
|
51
|
+
job_store: ExportJobStore | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(sessions, backend)
|
|
54
|
+
self._record_tools = RecordTools(sessions, backend)
|
|
55
|
+
self._job_store = job_store or ExportJobStore()
|
|
56
|
+
|
|
57
|
+
def register(self, mcp: FastMCP) -> None:
|
|
58
|
+
@mcp.tool(description="Start an asynchronous xlsx export for a Qingflow record view and return an opaque export_handle.")
|
|
59
|
+
def record_export_start(
|
|
60
|
+
profile: str = DEFAULT_PROFILE,
|
|
61
|
+
app_key: str = "",
|
|
62
|
+
view_id: str = "system:all",
|
|
63
|
+
columns: list[JSONObject | int] | None = None,
|
|
64
|
+
where: list[JSONObject] | None = None,
|
|
65
|
+
order_by: list[JSONObject] | None = None,
|
|
66
|
+
record_ids: list[str | int] | None = None,
|
|
67
|
+
include_workflow_log: bool = False,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
return self.record_export_start(
|
|
70
|
+
profile=profile,
|
|
71
|
+
app_key=app_key,
|
|
72
|
+
view_id=view_id,
|
|
73
|
+
columns=columns or [],
|
|
74
|
+
where=where or [],
|
|
75
|
+
order_by=order_by or [],
|
|
76
|
+
record_ids=record_ids or [],
|
|
77
|
+
include_workflow_log=include_workflow_log,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@mcp.tool(description="Get asynchronous record export status by opaque export_handle.")
|
|
81
|
+
def record_export_status_get(
|
|
82
|
+
profile: str = DEFAULT_PROFILE,
|
|
83
|
+
export_handle: str = "",
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
return self.record_export_status_get(
|
|
86
|
+
profile=profile,
|
|
87
|
+
export_handle=export_handle,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@mcp.tool(description="Get completed record export result, returning remote file links and optionally downloading files locally.")
|
|
91
|
+
def record_export_get(
|
|
92
|
+
profile: str = DEFAULT_PROFILE,
|
|
93
|
+
export_handle: str = "",
|
|
94
|
+
download_to_path: str | None = None,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
return self.record_export_get(
|
|
97
|
+
profile=profile,
|
|
98
|
+
export_handle=export_handle,
|
|
99
|
+
download_to_path=download_to_path,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@mcp.tool(description="Start a record export, wait for completion, and download the xlsx locally while also returning remote download links.")
|
|
103
|
+
def record_export_direct(
|
|
104
|
+
profile: str = DEFAULT_PROFILE,
|
|
105
|
+
app_key: str = "",
|
|
106
|
+
view_id: str = "system:all",
|
|
107
|
+
columns: list[JSONObject | int] | None = None,
|
|
108
|
+
where: list[JSONObject] | None = None,
|
|
109
|
+
order_by: list[JSONObject] | None = None,
|
|
110
|
+
record_ids: list[str | int] | None = None,
|
|
111
|
+
include_workflow_log: bool = False,
|
|
112
|
+
download_to_path: str | None = None,
|
|
113
|
+
wait_timeout_seconds: float | None = None,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
return self.record_export_direct(
|
|
116
|
+
profile=profile,
|
|
117
|
+
app_key=app_key,
|
|
118
|
+
view_id=view_id,
|
|
119
|
+
columns=columns or [],
|
|
120
|
+
where=where or [],
|
|
121
|
+
order_by=order_by or [],
|
|
122
|
+
record_ids=record_ids or [],
|
|
123
|
+
include_workflow_log=include_workflow_log,
|
|
124
|
+
download_to_path=download_to_path,
|
|
125
|
+
wait_timeout_seconds=wait_timeout_seconds,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@tool_cn_name("记录导出启动")
|
|
129
|
+
def record_export_start(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
profile: str = DEFAULT_PROFILE,
|
|
133
|
+
app_key: str,
|
|
134
|
+
view_id: str = "system:all",
|
|
135
|
+
columns: list[JSONObject | int] | None = None,
|
|
136
|
+
where: list[JSONObject] | None = None,
|
|
137
|
+
order_by: list[JSONObject] | None = None,
|
|
138
|
+
record_ids: list[str | int] | None = None,
|
|
139
|
+
include_workflow_log: bool = False,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
normalized_app_key = str(app_key or "").strip()
|
|
142
|
+
normalized_view_id = str(view_id or "").strip() or "system:all"
|
|
143
|
+
normalized_columns = _normalize_export_columns(columns or [])
|
|
144
|
+
normalized_where = self._record_tools._normalize_record_list_where(where or [])
|
|
145
|
+
normalized_order_by = self._record_tools._normalize_record_list_order_by(order_by or [])
|
|
146
|
+
normalized_record_ids = _normalize_export_record_ids(record_ids or [])
|
|
147
|
+
if not normalized_app_key:
|
|
148
|
+
return self._failed_export_result(
|
|
149
|
+
error_code="EXPORT_START_FAILED",
|
|
150
|
+
message="app_key is required",
|
|
151
|
+
extra={"view_id": normalized_view_id, "status": "failed"},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def runner(session_profile, context):
|
|
155
|
+
resolved_view, compatibility_warnings = self._record_tools._resolve_accessible_view_route(
|
|
156
|
+
profile,
|
|
157
|
+
context,
|
|
158
|
+
normalized_app_key,
|
|
159
|
+
view_id=normalized_view_id,
|
|
160
|
+
list_type=None,
|
|
161
|
+
view_key=None,
|
|
162
|
+
view_name=None,
|
|
163
|
+
allow_default=True,
|
|
164
|
+
)
|
|
165
|
+
export_config, export_config_warnings = self._build_export_config(
|
|
166
|
+
profile=profile,
|
|
167
|
+
context=context,
|
|
168
|
+
app_key=normalized_app_key,
|
|
169
|
+
resolved_view=resolved_view,
|
|
170
|
+
column_selectors=normalized_columns,
|
|
171
|
+
)
|
|
172
|
+
effective_record_ids, row_scope = self._resolve_export_record_scope(
|
|
173
|
+
profile=profile,
|
|
174
|
+
context=context,
|
|
175
|
+
app_key=normalized_app_key,
|
|
176
|
+
resolved_view=resolved_view,
|
|
177
|
+
selected_record_ids=normalized_record_ids,
|
|
178
|
+
where_filters=normalized_where,
|
|
179
|
+
order_by=normalized_order_by,
|
|
180
|
+
select_columns=cast(list[JSONObject], export_config.get("questionExportConfigList") or []),
|
|
181
|
+
)
|
|
182
|
+
result_amount = self._estimate_export_result_amount(
|
|
183
|
+
context,
|
|
184
|
+
app_key=normalized_app_key,
|
|
185
|
+
resolved_view=resolved_view,
|
|
186
|
+
selected_record_ids=effective_record_ids,
|
|
187
|
+
)
|
|
188
|
+
self._validate_export_limits(
|
|
189
|
+
result_amount=result_amount,
|
|
190
|
+
selected_field_count=len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
|
|
191
|
+
include_workflow_log=include_workflow_log,
|
|
192
|
+
)
|
|
193
|
+
filter_bean = self._build_export_filter_bean(
|
|
194
|
+
resolved_view,
|
|
195
|
+
selected_record_ids=effective_record_ids,
|
|
196
|
+
order_by=normalized_order_by,
|
|
197
|
+
row_scope=row_scope,
|
|
198
|
+
include_workflow_log=include_workflow_log,
|
|
199
|
+
)
|
|
200
|
+
started_at = _utc_now().replace(microsecond=0).isoformat()
|
|
201
|
+
socket_result = self.backend.start_socket_record_export(
|
|
202
|
+
context,
|
|
203
|
+
app_key=normalized_app_key,
|
|
204
|
+
view_id=resolved_view.view_id,
|
|
205
|
+
view_key=resolved_view.view_selection.view_key if resolved_view.view_selection is not None else None,
|
|
206
|
+
filter_bean=filter_bean,
|
|
207
|
+
export_config=export_config,
|
|
208
|
+
result_amount=result_amount,
|
|
209
|
+
)
|
|
210
|
+
column_scope = "selected" if normalized_columns else "all"
|
|
211
|
+
export_handle = uuid4().hex
|
|
212
|
+
self._job_store.put(
|
|
213
|
+
export_handle,
|
|
214
|
+
{
|
|
215
|
+
"created_at": started_at,
|
|
216
|
+
"profile": profile,
|
|
217
|
+
"base_url": context.base_url,
|
|
218
|
+
"ws_id": context.ws_id,
|
|
219
|
+
"qf_version": context.qf_version,
|
|
220
|
+
"qf_version_source": context.qf_version_source,
|
|
221
|
+
"app_key": normalized_app_key,
|
|
222
|
+
"view_id": resolved_view.view_id,
|
|
223
|
+
"backend_export_id": str(socket_result.get("backend_export_id") or ""),
|
|
224
|
+
"started_at": started_at,
|
|
225
|
+
"uid": session_profile.uid,
|
|
226
|
+
"row_scope": row_scope,
|
|
227
|
+
"selected_record_count": len(effective_record_ids),
|
|
228
|
+
"field_scope": column_scope,
|
|
229
|
+
"selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
|
|
230
|
+
"include_workflow_log": bool(include_workflow_log),
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
warnings = [
|
|
234
|
+
*deepcopy(compatibility_warnings),
|
|
235
|
+
*deepcopy(export_config_warnings),
|
|
236
|
+
*deepcopy(cast(list[JSONObject], socket_result.get("warnings") or [])),
|
|
237
|
+
]
|
|
238
|
+
return {
|
|
239
|
+
"ok": True,
|
|
240
|
+
"status": "accepted",
|
|
241
|
+
"app_key": normalized_app_key,
|
|
242
|
+
"view_id": resolved_view.view_id,
|
|
243
|
+
"export_handle": export_handle,
|
|
244
|
+
"row_scope": row_scope,
|
|
245
|
+
"selected_record_count": len(effective_record_ids),
|
|
246
|
+
"field_scope": column_scope,
|
|
247
|
+
"selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
|
|
248
|
+
"include_workflow_log": bool(include_workflow_log),
|
|
249
|
+
"file_urls": [],
|
|
250
|
+
"file_names": [],
|
|
251
|
+
"downloaded_files": [],
|
|
252
|
+
"warnings": warnings,
|
|
253
|
+
"verification": {
|
|
254
|
+
"export_acknowledged": bool(socket_result.get("backend_export_id")),
|
|
255
|
+
"view_route_supported": True,
|
|
256
|
+
"row_selection_applied": bool(effective_record_ids),
|
|
257
|
+
"query_filter_applied": bool(normalized_where),
|
|
258
|
+
"query_sort_applied": bool(normalized_order_by),
|
|
259
|
+
"field_selection_applied": bool(normalized_columns),
|
|
260
|
+
},
|
|
261
|
+
"request_route": self.backend.describe_route(context),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
return self._run(profile, runner)
|
|
266
|
+
except RuntimeError as exc:
|
|
267
|
+
return self._runtime_error_as_result(
|
|
268
|
+
exc,
|
|
269
|
+
error_code="EXPORT_START_FAILED",
|
|
270
|
+
extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
@tool_cn_name("记录导出状态")
|
|
274
|
+
def record_export_status_get(
|
|
275
|
+
self,
|
|
276
|
+
*,
|
|
277
|
+
profile: str = DEFAULT_PROFILE,
|
|
278
|
+
export_handle: str,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
normalized_handle = str(export_handle or "").strip()
|
|
281
|
+
if not normalized_handle:
|
|
282
|
+
return self._failed_export_result(
|
|
283
|
+
error_code="CONFIG_ERROR",
|
|
284
|
+
message="export_handle is required",
|
|
285
|
+
extra={"status": "failed"},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def runner(session_profile, context):
|
|
289
|
+
local_job = self._job_store.get(normalized_handle)
|
|
290
|
+
if local_job is None:
|
|
291
|
+
return self._failed_export_result(
|
|
292
|
+
error_code="EXPORT_HANDLE_UNKNOWN",
|
|
293
|
+
message="export_handle is missing or expired",
|
|
294
|
+
extra={"export_handle": normalized_handle, "status": "failed"},
|
|
295
|
+
)
|
|
296
|
+
lookup_context = self._build_export_lookup_context(
|
|
297
|
+
profile=profile,
|
|
298
|
+
session_profile=session_profile,
|
|
299
|
+
current_context=context,
|
|
300
|
+
local_job=local_job,
|
|
301
|
+
)
|
|
302
|
+
snapshot = self._resolve_export_snapshot(lookup_context, local_job)
|
|
303
|
+
return self._status_payload_from_snapshot(local_job, normalized_handle, snapshot)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
return self._run(profile, runner)
|
|
307
|
+
except RuntimeError as exc:
|
|
308
|
+
return self._runtime_error_as_result(
|
|
309
|
+
exc,
|
|
310
|
+
error_code="EXPORT_STATUS_FAILED",
|
|
311
|
+
extra={"export_handle": normalized_handle},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
@tool_cn_name("记录导出结果")
|
|
315
|
+
def record_export_get(
|
|
316
|
+
self,
|
|
317
|
+
*,
|
|
318
|
+
profile: str = DEFAULT_PROFILE,
|
|
319
|
+
export_handle: str,
|
|
320
|
+
download_to_path: str | None = None,
|
|
321
|
+
) -> dict[str, Any]:
|
|
322
|
+
normalized_handle = str(export_handle or "").strip()
|
|
323
|
+
if not normalized_handle:
|
|
324
|
+
return self._failed_export_result(
|
|
325
|
+
error_code="CONFIG_ERROR",
|
|
326
|
+
message="export_handle is required",
|
|
327
|
+
extra={"status": "failed"},
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def runner(session_profile, context):
|
|
331
|
+
local_job = self._job_store.get(normalized_handle)
|
|
332
|
+
if local_job is None:
|
|
333
|
+
return self._failed_export_result(
|
|
334
|
+
error_code="EXPORT_HANDLE_UNKNOWN",
|
|
335
|
+
message="export_handle is missing or expired",
|
|
336
|
+
extra={"export_handle": normalized_handle, "status": "failed"},
|
|
337
|
+
)
|
|
338
|
+
lookup_context = self._build_export_lookup_context(
|
|
339
|
+
profile=profile,
|
|
340
|
+
session_profile=session_profile,
|
|
341
|
+
current_context=context,
|
|
342
|
+
local_job=local_job,
|
|
343
|
+
)
|
|
344
|
+
snapshot = self._resolve_export_snapshot(lookup_context, local_job)
|
|
345
|
+
normalized_status = str(snapshot.get("status") or "unknown")
|
|
346
|
+
if normalized_status not in {"succeeded", "failed"}:
|
|
347
|
+
return self._failed_export_result(
|
|
348
|
+
error_code="EXPORT_NOT_READY",
|
|
349
|
+
message="export is not ready yet",
|
|
350
|
+
extra={
|
|
351
|
+
"status": "blocked",
|
|
352
|
+
"export_handle": normalized_handle,
|
|
353
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
354
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
355
|
+
"row_scope": str(local_job.get("row_scope") or "all"),
|
|
356
|
+
"selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
|
|
357
|
+
"field_scope": str(local_job.get("field_scope") or "all"),
|
|
358
|
+
"selected_field_count": _coerce_int(local_job.get("selected_field_count")),
|
|
359
|
+
"include_workflow_log": bool(local_job.get("include_workflow_log")),
|
|
360
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
361
|
+
"file_names": snapshot.get("file_names") or [],
|
|
362
|
+
"downloaded_files": [],
|
|
363
|
+
"warnings": snapshot.get("warnings") or [],
|
|
364
|
+
"verification": snapshot.get("verification") or {},
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
if normalized_status == "failed":
|
|
368
|
+
return {
|
|
369
|
+
**self._failed_export_result(
|
|
370
|
+
error_code=str(snapshot.get("error_code") or "EXPORT_FAILED"),
|
|
371
|
+
message=str(snapshot.get("message") or "export failed"),
|
|
372
|
+
extra={
|
|
373
|
+
"status": "failed",
|
|
374
|
+
"export_handle": normalized_handle,
|
|
375
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
376
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
377
|
+
"row_scope": str(local_job.get("row_scope") or "all"),
|
|
378
|
+
"selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
|
|
379
|
+
"field_scope": str(local_job.get("field_scope") or "all"),
|
|
380
|
+
"selected_field_count": _coerce_int(local_job.get("selected_field_count")),
|
|
381
|
+
"include_workflow_log": bool(local_job.get("include_workflow_log")),
|
|
382
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
383
|
+
"file_names": snapshot.get("file_names") or [],
|
|
384
|
+
"downloaded_files": [],
|
|
385
|
+
"warnings": snapshot.get("warnings") or [],
|
|
386
|
+
"verification": snapshot.get("verification") or {},
|
|
387
|
+
"num": snapshot.get("num"),
|
|
388
|
+
"process_status": snapshot.get("process_status"),
|
|
389
|
+
"audit_record_status": snapshot.get("audit_record_status"),
|
|
390
|
+
},
|
|
391
|
+
),
|
|
392
|
+
}
|
|
393
|
+
file_infos = cast(list[JSONObject], snapshot.get("file_infos") or [])
|
|
394
|
+
if not file_infos:
|
|
395
|
+
return self._failed_export_result(
|
|
396
|
+
error_code="EXPORT_FILE_UNAVAILABLE",
|
|
397
|
+
message="export completed but did not return downloadable files",
|
|
398
|
+
extra={
|
|
399
|
+
"status": "failed",
|
|
400
|
+
"export_handle": normalized_handle,
|
|
401
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
402
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
403
|
+
"warnings": snapshot.get("warnings") or [],
|
|
404
|
+
"verification": snapshot.get("verification") or {},
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
downloaded_files = self._download_export_files(
|
|
408
|
+
file_infos=file_infos,
|
|
409
|
+
download_to_path=download_to_path,
|
|
410
|
+
default_directory=None,
|
|
411
|
+
app_key=str(local_job.get("app_key") or ""),
|
|
412
|
+
view_id=str(local_job.get("view_id") or ""),
|
|
413
|
+
)
|
|
414
|
+
return {
|
|
415
|
+
"ok": True,
|
|
416
|
+
"status": "succeeded",
|
|
417
|
+
"export_handle": normalized_handle,
|
|
418
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
419
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
420
|
+
"row_scope": str(local_job.get("row_scope") or "all"),
|
|
421
|
+
"selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
|
|
422
|
+
"field_scope": str(local_job.get("field_scope") or "all"),
|
|
423
|
+
"selected_field_count": _coerce_int(local_job.get("selected_field_count")),
|
|
424
|
+
"include_workflow_log": bool(local_job.get("include_workflow_log")),
|
|
425
|
+
"num": snapshot.get("num"),
|
|
426
|
+
"process_status": snapshot.get("process_status"),
|
|
427
|
+
"error_code": snapshot.get("error_code"),
|
|
428
|
+
"audit_record_status": snapshot.get("audit_record_status"),
|
|
429
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
430
|
+
"file_names": snapshot.get("file_names") or [],
|
|
431
|
+
"downloaded_files": downloaded_files,
|
|
432
|
+
"warnings": snapshot.get("warnings") or [],
|
|
433
|
+
"verification": snapshot.get("verification") or {},
|
|
434
|
+
"request_route": self.backend.describe_route(lookup_context),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
return self._run(profile, runner)
|
|
439
|
+
except RuntimeError as exc:
|
|
440
|
+
return self._runtime_error_as_result(
|
|
441
|
+
exc,
|
|
442
|
+
error_code="EXPORT_GET_FAILED",
|
|
443
|
+
extra={"export_handle": normalized_handle},
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
@tool_cn_name("记录直接导出")
|
|
447
|
+
def record_export_direct(
|
|
448
|
+
self,
|
|
449
|
+
*,
|
|
450
|
+
profile: str = DEFAULT_PROFILE,
|
|
451
|
+
app_key: str,
|
|
452
|
+
view_id: str = "system:all",
|
|
453
|
+
columns: list[JSONObject | int] | None = None,
|
|
454
|
+
where: list[JSONObject] | None = None,
|
|
455
|
+
order_by: list[JSONObject] | None = None,
|
|
456
|
+
record_ids: list[str | int] | None = None,
|
|
457
|
+
include_workflow_log: bool = False,
|
|
458
|
+
download_to_path: str | None = None,
|
|
459
|
+
wait_timeout_seconds: float | None = None,
|
|
460
|
+
) -> dict[str, Any]:
|
|
461
|
+
normalized_app_key = str(app_key or "").strip()
|
|
462
|
+
normalized_view_id = str(view_id or "").strip() or "system:all"
|
|
463
|
+
if not normalized_app_key:
|
|
464
|
+
return self._failed_export_result(
|
|
465
|
+
error_code="EXPORT_START_FAILED",
|
|
466
|
+
message="app_key is required",
|
|
467
|
+
extra={"status": "failed", "view_id": normalized_view_id},
|
|
468
|
+
)
|
|
469
|
+
timeout_seconds = self._normalize_timeout_seconds(wait_timeout_seconds)
|
|
470
|
+
|
|
471
|
+
def runner(session_profile, context):
|
|
472
|
+
start_result = self.record_export_start(
|
|
473
|
+
profile=profile,
|
|
474
|
+
app_key=normalized_app_key,
|
|
475
|
+
view_id=normalized_view_id,
|
|
476
|
+
columns=columns or [],
|
|
477
|
+
where=where or [],
|
|
478
|
+
order_by=order_by or [],
|
|
479
|
+
record_ids=record_ids or [],
|
|
480
|
+
include_workflow_log=include_workflow_log,
|
|
481
|
+
)
|
|
482
|
+
if not bool(start_result.get("ok")):
|
|
483
|
+
return start_result
|
|
484
|
+
export_handle = str(start_result.get("export_handle") or "")
|
|
485
|
+
deadline = monotonic() + timeout_seconds
|
|
486
|
+
last_snapshot: dict[str, Any] | None = None
|
|
487
|
+
while monotonic() < deadline:
|
|
488
|
+
local_job = self._job_store.get(export_handle)
|
|
489
|
+
if local_job is None:
|
|
490
|
+
return self._failed_export_result(
|
|
491
|
+
error_code="EXPORT_HANDLE_UNKNOWN",
|
|
492
|
+
message="export_handle is missing or expired",
|
|
493
|
+
extra={"status": "failed", "export_handle": export_handle},
|
|
494
|
+
)
|
|
495
|
+
lookup_context = self._build_export_lookup_context(
|
|
496
|
+
profile=profile,
|
|
497
|
+
session_profile=session_profile,
|
|
498
|
+
current_context=context,
|
|
499
|
+
local_job=local_job,
|
|
500
|
+
)
|
|
501
|
+
snapshot = self._resolve_export_snapshot(lookup_context, local_job)
|
|
502
|
+
last_snapshot = snapshot
|
|
503
|
+
normalized_status = str(snapshot.get("status") or "unknown")
|
|
504
|
+
if normalized_status == "succeeded":
|
|
505
|
+
effective_download_path = download_to_path or str(Path.cwd())
|
|
506
|
+
get_result = self.record_export_get(
|
|
507
|
+
profile=profile,
|
|
508
|
+
export_handle=export_handle,
|
|
509
|
+
download_to_path=effective_download_path,
|
|
510
|
+
)
|
|
511
|
+
if bool(get_result.get("ok")):
|
|
512
|
+
return get_result
|
|
513
|
+
return {
|
|
514
|
+
**get_result,
|
|
515
|
+
"export_handle": export_handle,
|
|
516
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
517
|
+
"file_names": snapshot.get("file_names") or [],
|
|
518
|
+
}
|
|
519
|
+
if normalized_status == "failed":
|
|
520
|
+
return {
|
|
521
|
+
"ok": False,
|
|
522
|
+
"status": "failed",
|
|
523
|
+
"error_code": str(snapshot.get("error_code") or "EXPORT_FAILED"),
|
|
524
|
+
"message": str(snapshot.get("message") or "export failed"),
|
|
525
|
+
"export_handle": export_handle,
|
|
526
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
527
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
528
|
+
"row_scope": str(local_job.get("row_scope") or "all"),
|
|
529
|
+
"selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
|
|
530
|
+
"field_scope": str(local_job.get("field_scope") or "all"),
|
|
531
|
+
"selected_field_count": _coerce_int(local_job.get("selected_field_count")),
|
|
532
|
+
"include_workflow_log": bool(local_job.get("include_workflow_log")),
|
|
533
|
+
"num": snapshot.get("num"),
|
|
534
|
+
"process_status": snapshot.get("process_status"),
|
|
535
|
+
"audit_record_status": snapshot.get("audit_record_status"),
|
|
536
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
537
|
+
"file_names": snapshot.get("file_names") or [],
|
|
538
|
+
"downloaded_files": [],
|
|
539
|
+
"warnings": snapshot.get("warnings") or [],
|
|
540
|
+
"verification": snapshot.get("verification") or {},
|
|
541
|
+
}
|
|
542
|
+
if normalized_status == "unknown":
|
|
543
|
+
warning_codes = {
|
|
544
|
+
str(item.get("code") or "")
|
|
545
|
+
for item in cast(list[JSONObject], snapshot.get("warnings") or [])
|
|
546
|
+
if isinstance(item, dict)
|
|
547
|
+
}
|
|
548
|
+
if "EXPORT_HISTORY_AMBIGUOUS" in warning_codes:
|
|
549
|
+
return {
|
|
550
|
+
"ok": True,
|
|
551
|
+
"status": "unknown",
|
|
552
|
+
"export_handle": export_handle,
|
|
553
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
554
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
555
|
+
"row_scope": str(local_job.get("row_scope") or "all"),
|
|
556
|
+
"selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
|
|
557
|
+
"field_scope": str(local_job.get("field_scope") or "all"),
|
|
558
|
+
"selected_field_count": _coerce_int(local_job.get("selected_field_count")),
|
|
559
|
+
"include_workflow_log": bool(local_job.get("include_workflow_log")),
|
|
560
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
561
|
+
"file_names": snapshot.get("file_names") or [],
|
|
562
|
+
"downloaded_files": [],
|
|
563
|
+
"warnings": snapshot.get("warnings") or [],
|
|
564
|
+
"verification": snapshot.get("verification") or {},
|
|
565
|
+
"message": "export result could not be matched uniquely",
|
|
566
|
+
}
|
|
567
|
+
remaining = deadline - monotonic()
|
|
568
|
+
if remaining <= 0:
|
|
569
|
+
break
|
|
570
|
+
sleep(min(EXPORT_POLL_INTERVAL_SECONDS, remaining))
|
|
571
|
+
timeout_status = "running"
|
|
572
|
+
if isinstance(last_snapshot, dict) and str(last_snapshot.get("status") or "").strip():
|
|
573
|
+
timeout_status = str(last_snapshot.get("status") or timeout_status)
|
|
574
|
+
warnings = list(cast(list[JSONObject], start_result.get("warnings") or []))
|
|
575
|
+
warnings.append(
|
|
576
|
+
{
|
|
577
|
+
"code": "EXPORT_WAIT_TIMEOUT",
|
|
578
|
+
"message": "export is still running; reuse export_handle with record_export_status_get or record_export_get",
|
|
579
|
+
}
|
|
580
|
+
)
|
|
581
|
+
return {
|
|
582
|
+
"ok": True,
|
|
583
|
+
"status": timeout_status,
|
|
584
|
+
"export_handle": str(start_result.get("export_handle") or ""),
|
|
585
|
+
"app_key": normalized_app_key,
|
|
586
|
+
"view_id": str(start_result.get("view_id") or normalized_view_id),
|
|
587
|
+
"row_scope": start_result.get("row_scope") or ("selected" if record_ids else "all"),
|
|
588
|
+
"selected_record_count": start_result.get("selected_record_count") or 0,
|
|
589
|
+
"field_scope": start_result.get("field_scope") or ("selected" if columns else "all"),
|
|
590
|
+
"selected_field_count": start_result.get("selected_field_count"),
|
|
591
|
+
"include_workflow_log": start_result.get("include_workflow_log"),
|
|
592
|
+
"file_urls": (last_snapshot or {}).get("file_urls") or [],
|
|
593
|
+
"file_names": (last_snapshot or {}).get("file_names") or [],
|
|
594
|
+
"downloaded_files": [],
|
|
595
|
+
"warnings": warnings,
|
|
596
|
+
"verification": (last_snapshot or {}).get("verification") or start_result.get("verification") or {},
|
|
597
|
+
"message": "export did not finish before wait_timeout_seconds",
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
try:
|
|
601
|
+
return self._run(profile, runner)
|
|
602
|
+
except RuntimeError as exc:
|
|
603
|
+
return self._runtime_error_as_result(
|
|
604
|
+
exc,
|
|
605
|
+
error_code="EXPORT_DIRECT_FAILED",
|
|
606
|
+
extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
def _build_export_lookup_context(
|
|
610
|
+
self,
|
|
611
|
+
*,
|
|
612
|
+
profile: str,
|
|
613
|
+
session_profile,
|
|
614
|
+
current_context: BackendRequestContext,
|
|
615
|
+
local_job: dict[str, Any],
|
|
616
|
+
) -> BackendRequestContext:
|
|
617
|
+
stored_profile = str(local_job.get("profile") or "").strip()
|
|
618
|
+
if stored_profile and stored_profile != profile:
|
|
619
|
+
raise QingflowApiError.config_error(
|
|
620
|
+
"export_handle was created under a different profile",
|
|
621
|
+
details={
|
|
622
|
+
"error_code": "EXPORT_HANDLE_PROFILE_MISMATCH",
|
|
623
|
+
"expected_profile": stored_profile,
|
|
624
|
+
"received_profile": profile,
|
|
625
|
+
},
|
|
626
|
+
)
|
|
627
|
+
stored_uid = _coerce_positive_int(local_job.get("uid"))
|
|
628
|
+
if stored_uid is not None and stored_uid != session_profile.uid:
|
|
629
|
+
raise QingflowApiError.config_error(
|
|
630
|
+
"export_handle belongs to a different authenticated user",
|
|
631
|
+
details={
|
|
632
|
+
"error_code": "EXPORT_HANDLE_OWNER_MISMATCH",
|
|
633
|
+
"expected_uid": stored_uid,
|
|
634
|
+
"current_uid": session_profile.uid,
|
|
635
|
+
},
|
|
636
|
+
)
|
|
637
|
+
stored_base_url = str(local_job.get("base_url") or "").strip() or current_context.base_url
|
|
638
|
+
stored_ws_id = _coerce_positive_int(local_job.get("ws_id"))
|
|
639
|
+
stored_qf_version = str(local_job.get("qf_version") or "").strip() or current_context.qf_version
|
|
640
|
+
stored_qf_version_source = (
|
|
641
|
+
str(local_job.get("qf_version_source") or "").strip() or current_context.qf_version_source
|
|
642
|
+
)
|
|
643
|
+
return BackendRequestContext(
|
|
644
|
+
base_url=stored_base_url,
|
|
645
|
+
token=current_context.token,
|
|
646
|
+
ws_id=stored_ws_id if stored_ws_id is not None else current_context.ws_id,
|
|
647
|
+
qf_request_id=current_context.qf_request_id,
|
|
648
|
+
qf_version=stored_qf_version,
|
|
649
|
+
qf_version_source=stored_qf_version_source,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
def _resolve_export_record_scope(
|
|
653
|
+
self,
|
|
654
|
+
*,
|
|
655
|
+
profile: str,
|
|
656
|
+
context,
|
|
657
|
+
app_key: str,
|
|
658
|
+
resolved_view: AccessibleViewRoute,
|
|
659
|
+
selected_record_ids: list[int],
|
|
660
|
+
where_filters: list[JSONObject],
|
|
661
|
+
order_by: list[JSONObject],
|
|
662
|
+
select_columns: list[JSONObject],
|
|
663
|
+
) -> tuple[list[int], str]:
|
|
664
|
+
if selected_record_ids and (where_filters or order_by):
|
|
665
|
+
raise QingflowApiError(
|
|
666
|
+
category="config",
|
|
667
|
+
message="record export does not allow record_ids together with query selectors",
|
|
668
|
+
details={
|
|
669
|
+
"error_code": "EXPORT_ROW_SCOPE_CONFLICT",
|
|
670
|
+
"fix_hint": "Use record_ids for explicit selected rows, or use where/order_by for internal query selection, but not both.",
|
|
671
|
+
},
|
|
672
|
+
)
|
|
673
|
+
if selected_record_ids:
|
|
674
|
+
return selected_record_ids, "selected"
|
|
675
|
+
if not where_filters and not order_by:
|
|
676
|
+
return [], "all"
|
|
677
|
+
resolved_ids = self._collect_record_ids_from_query(
|
|
678
|
+
profile=profile,
|
|
679
|
+
context=context,
|
|
680
|
+
app_key=app_key,
|
|
681
|
+
resolved_view=resolved_view,
|
|
682
|
+
where_filters=where_filters,
|
|
683
|
+
order_by=order_by,
|
|
684
|
+
select_columns=select_columns,
|
|
685
|
+
)
|
|
686
|
+
if not resolved_ids:
|
|
687
|
+
raise QingflowApiError.config_error(
|
|
688
|
+
"record export query did not match any records",
|
|
689
|
+
details={
|
|
690
|
+
"error_code": "EXPORT_NO_MATCHED_RECORDS",
|
|
691
|
+
"view_id": resolved_view.view_id,
|
|
692
|
+
},
|
|
693
|
+
)
|
|
694
|
+
return resolved_ids, "queried"
|
|
695
|
+
|
|
696
|
+
def _collect_record_ids_from_query(
|
|
697
|
+
self,
|
|
698
|
+
*,
|
|
699
|
+
profile: str,
|
|
700
|
+
context,
|
|
701
|
+
app_key: str,
|
|
702
|
+
resolved_view: AccessibleViewRoute,
|
|
703
|
+
where_filters: list[JSONObject],
|
|
704
|
+
order_by: list[JSONObject],
|
|
705
|
+
select_columns: list[JSONObject],
|
|
706
|
+
) -> list[int]:
|
|
707
|
+
browse_scope = self._record_tools._build_browse_write_scope(
|
|
708
|
+
profile,
|
|
709
|
+
context,
|
|
710
|
+
app_key,
|
|
711
|
+
resolved_view,
|
|
712
|
+
force_refresh=False,
|
|
713
|
+
)
|
|
714
|
+
index = browse_scope["index"]
|
|
715
|
+
match_rules = self._record_tools._resolve_match_rules(context, where_filters, index)
|
|
716
|
+
query_sorts = [
|
|
717
|
+
{
|
|
718
|
+
"queId": _coerce_int(item.get("field_id")),
|
|
719
|
+
"direction": str(item.get("direction") or "asc"),
|
|
720
|
+
}
|
|
721
|
+
for item in order_by
|
|
722
|
+
if isinstance(item, dict) and _coerce_int(item.get("field_id")) is not None
|
|
723
|
+
]
|
|
724
|
+
dept_member_cache: dict[int, set[int]] = {}
|
|
725
|
+
current_page = 1
|
|
726
|
+
selected_ids: list[int] = []
|
|
727
|
+
seen: set[int] = set()
|
|
728
|
+
primary_field_ids = [
|
|
729
|
+
_coerce_int(item.get("queId"))
|
|
730
|
+
for item in select_columns
|
|
731
|
+
if isinstance(item, dict)
|
|
732
|
+
]
|
|
733
|
+
primary_search_que_ids = [item for item in primary_field_ids if item is not None][:1] or None
|
|
734
|
+
|
|
735
|
+
while True:
|
|
736
|
+
page = self._record_tools._search_page(
|
|
737
|
+
context,
|
|
738
|
+
app_key=app_key,
|
|
739
|
+
view_selection=resolved_view.view_selection,
|
|
740
|
+
page_num=current_page,
|
|
741
|
+
page_size=DEFAULT_LIST_PAGE_SIZE,
|
|
742
|
+
query_key=None,
|
|
743
|
+
match_rules=match_rules,
|
|
744
|
+
sorts=cast(list[JSONObject], query_sorts),
|
|
745
|
+
search_que_ids=primary_search_que_ids,
|
|
746
|
+
list_type=resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
|
|
747
|
+
)
|
|
748
|
+
raw_rows = page.get("list")
|
|
749
|
+
items = raw_rows if isinstance(raw_rows, list) else []
|
|
750
|
+
for item in items:
|
|
751
|
+
if not isinstance(item, dict):
|
|
752
|
+
continue
|
|
753
|
+
answers = item.get("answers")
|
|
754
|
+
answer_list = answers if isinstance(answers, list) else []
|
|
755
|
+
if not self._record_tools._matches_view_selection(
|
|
756
|
+
context,
|
|
757
|
+
answer_list,
|
|
758
|
+
view_selection=resolved_view.view_selection,
|
|
759
|
+
dept_member_cache=dept_member_cache,
|
|
760
|
+
):
|
|
761
|
+
continue
|
|
762
|
+
record_id = _coerce_int(item.get("applyId"))
|
|
763
|
+
if record_id is None:
|
|
764
|
+
record_id = _coerce_int(item.get("apply_id"))
|
|
765
|
+
if record_id is None:
|
|
766
|
+
record_id = _coerce_int(item.get("id"))
|
|
767
|
+
if record_id is None:
|
|
768
|
+
record_id = _coerce_int(item.get("record_id"))
|
|
769
|
+
if record_id is None or record_id in seen or record_id <= 0:
|
|
770
|
+
continue
|
|
771
|
+
seen.add(record_id)
|
|
772
|
+
selected_ids.append(record_id)
|
|
773
|
+
if len(selected_ids) > EXPORT_ROWS_LIMIT:
|
|
774
|
+
raise QingflowApiError.config_error(
|
|
775
|
+
f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
|
|
776
|
+
details={
|
|
777
|
+
"error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
|
|
778
|
+
"result_amount": len(selected_ids),
|
|
779
|
+
"row_limit": EXPORT_ROWS_LIMIT,
|
|
780
|
+
},
|
|
781
|
+
)
|
|
782
|
+
if current_page == 1:
|
|
783
|
+
reported_total = _effective_total(page, page_size=DEFAULT_LIST_PAGE_SIZE)
|
|
784
|
+
if reported_total > EXPORT_ROWS_LIMIT:
|
|
785
|
+
raise QingflowApiError.config_error(
|
|
786
|
+
f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
|
|
787
|
+
details={
|
|
788
|
+
"error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
|
|
789
|
+
"result_amount": reported_total,
|
|
790
|
+
"row_limit": EXPORT_ROWS_LIMIT,
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
if not _page_has_more(page, current_page=current_page, page_size=DEFAULT_LIST_PAGE_SIZE, returned_rows=len(items)):
|
|
794
|
+
break
|
|
795
|
+
current_page += 1
|
|
796
|
+
return selected_ids
|
|
797
|
+
|
|
798
|
+
def _build_export_filter_bean(
|
|
799
|
+
self,
|
|
800
|
+
resolved_view: AccessibleViewRoute,
|
|
801
|
+
*,
|
|
802
|
+
selected_record_ids: list[int],
|
|
803
|
+
order_by: list[JSONObject],
|
|
804
|
+
row_scope: str,
|
|
805
|
+
include_workflow_log: bool,
|
|
806
|
+
) -> JSONObject:
|
|
807
|
+
filter_payload: JSONObject = {}
|
|
808
|
+
if resolved_view.kind == "system" and resolved_view.list_type is not None:
|
|
809
|
+
filter_payload["type"] = resolved_view.list_type
|
|
810
|
+
elif resolved_view.kind == "custom":
|
|
811
|
+
# Custom-view native export later flows through shared data-export code that
|
|
812
|
+
# expects a list semantics integer. Frontend export treats custom views as
|
|
813
|
+
# creator-all style export, so mirror LIST_CREATOR_ALL here.
|
|
814
|
+
filter_payload["type"] = DEFAULT_RECORD_LIST_TYPE
|
|
815
|
+
if selected_record_ids:
|
|
816
|
+
filter_payload["applyIds"] = selected_record_ids
|
|
817
|
+
if row_scope == "queried" and order_by:
|
|
818
|
+
normalized_sorts = [
|
|
819
|
+
{
|
|
820
|
+
"queId": field_id,
|
|
821
|
+
"isAscend": str(item.get("direction") or "asc").strip().lower() != "desc",
|
|
822
|
+
}
|
|
823
|
+
for item in order_by
|
|
824
|
+
if isinstance(item, dict) and (field_id := _coerce_int(item.get("field_id"))) is not None
|
|
825
|
+
]
|
|
826
|
+
if normalized_sorts:
|
|
827
|
+
filter_payload["sorts"] = normalized_sorts
|
|
828
|
+
return {
|
|
829
|
+
"filter": filter_payload,
|
|
830
|
+
# Backend export code later auto-unboxes this field to primitive boolean.
|
|
831
|
+
# Always send an explicit boolean to avoid a null -> NPE -> 41100 failure path.
|
|
832
|
+
"auditRecordStatus": bool(include_workflow_log),
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
def _build_export_config(
|
|
836
|
+
self,
|
|
837
|
+
*,
|
|
838
|
+
profile: str,
|
|
839
|
+
context,
|
|
840
|
+
app_key: str,
|
|
841
|
+
resolved_view: AccessibleViewRoute,
|
|
842
|
+
column_selectors: list[int],
|
|
843
|
+
) -> tuple[JSONObject, list[JSONObject]]: # type: ignore[no-untyped-def]
|
|
844
|
+
browse_scope = self._record_tools._build_browse_write_scope(
|
|
845
|
+
profile,
|
|
846
|
+
context,
|
|
847
|
+
app_key,
|
|
848
|
+
resolved_view,
|
|
849
|
+
force_refresh=False,
|
|
850
|
+
)
|
|
851
|
+
index = browse_scope["index"]
|
|
852
|
+
visible_question_ids = cast(set[int], browse_scope.get("visible_question_ids") or set())
|
|
853
|
+
ordered_visible_fields, warnings = self._resolve_exportable_fields(
|
|
854
|
+
profile=profile,
|
|
855
|
+
context=context,
|
|
856
|
+
app_key=app_key,
|
|
857
|
+
resolved_view=resolved_view,
|
|
858
|
+
index=index,
|
|
859
|
+
visible_question_ids=visible_question_ids,
|
|
860
|
+
)
|
|
861
|
+
if not ordered_visible_fields:
|
|
862
|
+
ordered_visible_fields = [
|
|
863
|
+
field
|
|
864
|
+
for field in cast(Any, index).by_id.values()
|
|
865
|
+
if field.que_type not in LAYOUT_ONLY_QUE_TYPES
|
|
866
|
+
]
|
|
867
|
+
exportable_by_id = {field.que_id: field for field in ordered_visible_fields}
|
|
868
|
+
selected_fields: list[Any]
|
|
869
|
+
if column_selectors:
|
|
870
|
+
selected_fields = []
|
|
871
|
+
seen: set[int] = set()
|
|
872
|
+
for selector in column_selectors:
|
|
873
|
+
field = self._record_tools._resolve_field_selector(selector, index, location="export_columns")
|
|
874
|
+
if field.que_id in seen:
|
|
875
|
+
continue
|
|
876
|
+
exportable_field = exportable_by_id.get(field.que_id)
|
|
877
|
+
if exportable_field is None:
|
|
878
|
+
raise QingflowApiError.config_error(
|
|
879
|
+
f"field '{field.que_title}' is not exportable in the selected view",
|
|
880
|
+
details={
|
|
881
|
+
"error_code": "EXPORT_FIELD_NOT_VISIBLE",
|
|
882
|
+
"field_id": field.que_id,
|
|
883
|
+
"view_id": resolved_view.view_id,
|
|
884
|
+
},
|
|
885
|
+
)
|
|
886
|
+
selected_fields.append(exportable_field)
|
|
887
|
+
seen.add(field.que_id)
|
|
888
|
+
else:
|
|
889
|
+
selected_fields = ordered_visible_fields
|
|
890
|
+
question_export_config_list: list[JSONObject] = []
|
|
891
|
+
for field in selected_fields:
|
|
892
|
+
if field.que_type is None:
|
|
893
|
+
warnings.append(
|
|
894
|
+
{
|
|
895
|
+
"code": "EXPORT_FIELD_TYPE_UNAVAILABLE",
|
|
896
|
+
"message": f"Skipped field '{field.que_title}' because its field type could not be resolved.",
|
|
897
|
+
}
|
|
898
|
+
)
|
|
899
|
+
continue
|
|
900
|
+
question_export_config_list.append(
|
|
901
|
+
{
|
|
902
|
+
"queId": field.que_id,
|
|
903
|
+
"queTitle": field.que_title,
|
|
904
|
+
"queType": field.que_type,
|
|
905
|
+
"exportStyle": "default",
|
|
906
|
+
}
|
|
907
|
+
)
|
|
908
|
+
if not question_export_config_list:
|
|
909
|
+
raise QingflowApiError.config_error(
|
|
910
|
+
"record export could not determine exportable fields for the selected view",
|
|
911
|
+
details={"error_code": "EXPORT_CONFIG_UNAVAILABLE"},
|
|
912
|
+
)
|
|
913
|
+
return {"questionExportConfigList": question_export_config_list}, warnings
|
|
914
|
+
|
|
915
|
+
def _resolve_exportable_fields(
|
|
916
|
+
self,
|
|
917
|
+
*,
|
|
918
|
+
profile: str,
|
|
919
|
+
context,
|
|
920
|
+
app_key: str,
|
|
921
|
+
resolved_view: AccessibleViewRoute,
|
|
922
|
+
index,
|
|
923
|
+
visible_question_ids: set[int],
|
|
924
|
+
) -> tuple[list[FormField], list[JSONObject]]: # type: ignore[no-untyped-def]
|
|
925
|
+
warnings: list[JSONObject] = []
|
|
926
|
+
if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
|
|
927
|
+
custom_fields = self._resolve_custom_view_exportable_fields(
|
|
928
|
+
profile=profile,
|
|
929
|
+
context=context,
|
|
930
|
+
app_key=app_key,
|
|
931
|
+
view_key=resolved_view.view_selection.view_key,
|
|
932
|
+
index=index,
|
|
933
|
+
visible_question_ids=visible_question_ids,
|
|
934
|
+
)
|
|
935
|
+
if custom_fields is not None:
|
|
936
|
+
return custom_fields, warnings
|
|
937
|
+
warnings.append(
|
|
938
|
+
{
|
|
939
|
+
"code": "EXPORT_VIEW_CONFIG_PARTIAL",
|
|
940
|
+
"message": "custom view export fields fell back to schema order because viewConfig could not provide exportable field entries",
|
|
941
|
+
}
|
|
942
|
+
)
|
|
943
|
+
ordered_visible_fields = [
|
|
944
|
+
field
|
|
945
|
+
for field in self._record_tools._schema_fields_for_mode(
|
|
946
|
+
profile,
|
|
947
|
+
context,
|
|
948
|
+
app_key,
|
|
949
|
+
index,
|
|
950
|
+
schema_mode="browse",
|
|
951
|
+
resolved_view=resolved_view,
|
|
952
|
+
)
|
|
953
|
+
if field.que_id in visible_question_ids and field.que_type not in LAYOUT_ONLY_QUE_TYPES
|
|
954
|
+
]
|
|
955
|
+
return ordered_visible_fields, warnings
|
|
956
|
+
|
|
957
|
+
def _resolve_custom_view_exportable_fields(
|
|
958
|
+
self,
|
|
959
|
+
*,
|
|
960
|
+
profile: str,
|
|
961
|
+
context,
|
|
962
|
+
app_key: str,
|
|
963
|
+
view_key: str,
|
|
964
|
+
index,
|
|
965
|
+
visible_question_ids: set[int],
|
|
966
|
+
) -> list[FormField] | None: # type: ignore[no-untyped-def]
|
|
967
|
+
view_config = self._record_tools._get_view_config(profile, context, view_key)
|
|
968
|
+
if not isinstance(view_config, dict):
|
|
969
|
+
return None
|
|
970
|
+
entries = _extract_export_view_question_entries(view_config.get("viewgraphQuestions"))
|
|
971
|
+
if not entries:
|
|
972
|
+
return []
|
|
973
|
+
ordered_fields: list[FormField] = []
|
|
974
|
+
seen: set[int] = set()
|
|
975
|
+
for entry in entries:
|
|
976
|
+
if not bool(entry.get("downloadable", True)):
|
|
977
|
+
continue
|
|
978
|
+
field_id = _coerce_int(entry.get("field_id"))
|
|
979
|
+
if field_id is None or field_id in seen:
|
|
980
|
+
continue
|
|
981
|
+
field = cast(FormField | None, cast(Any, index).by_id.get(str(field_id)))
|
|
982
|
+
if field is None or field.que_type in LAYOUT_ONLY_QUE_TYPES:
|
|
983
|
+
continue
|
|
984
|
+
ordered_fields.append(field)
|
|
985
|
+
seen.add(field_id)
|
|
986
|
+
return ordered_fields
|
|
987
|
+
|
|
988
|
+
def _estimate_export_result_amount(
|
|
989
|
+
self,
|
|
990
|
+
context,
|
|
991
|
+
*,
|
|
992
|
+
app_key: str,
|
|
993
|
+
resolved_view: AccessibleViewRoute,
|
|
994
|
+
selected_record_ids: list[int],
|
|
995
|
+
) -> int:
|
|
996
|
+
if selected_record_ids:
|
|
997
|
+
return len(selected_record_ids)
|
|
998
|
+
page = self._record_tools._search_page(
|
|
999
|
+
context,
|
|
1000
|
+
app_key=app_key,
|
|
1001
|
+
view_selection=resolved_view.view_selection,
|
|
1002
|
+
page_num=1,
|
|
1003
|
+
page_size=1,
|
|
1004
|
+
query_key=None,
|
|
1005
|
+
match_rules=[],
|
|
1006
|
+
sorts=[],
|
|
1007
|
+
search_que_ids=None,
|
|
1008
|
+
list_type=resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
|
|
1009
|
+
)
|
|
1010
|
+
result_amount = _effective_total(page, page_size=1)
|
|
1011
|
+
if result_amount < 0:
|
|
1012
|
+
result_amount = 0
|
|
1013
|
+
return result_amount
|
|
1014
|
+
|
|
1015
|
+
def _validate_export_limits(
|
|
1016
|
+
self,
|
|
1017
|
+
*,
|
|
1018
|
+
result_amount: int,
|
|
1019
|
+
selected_field_count: int,
|
|
1020
|
+
include_workflow_log: bool,
|
|
1021
|
+
) -> None:
|
|
1022
|
+
if result_amount > EXPORT_ROWS_LIMIT:
|
|
1023
|
+
raise QingflowApiError.config_error(
|
|
1024
|
+
f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
|
|
1025
|
+
details={
|
|
1026
|
+
"error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
|
|
1027
|
+
"result_amount": result_amount,
|
|
1028
|
+
"row_limit": EXPORT_ROWS_LIMIT,
|
|
1029
|
+
},
|
|
1030
|
+
)
|
|
1031
|
+
estimated_cells = max(result_amount, 0) * max(selected_field_count, 0)
|
|
1032
|
+
if estimated_cells > EXPORT_CELL_LIMIT:
|
|
1033
|
+
raise QingflowApiError.config_error(
|
|
1034
|
+
f"record export exceeds the native cell limit of {EXPORT_CELL_LIMIT}",
|
|
1035
|
+
details={
|
|
1036
|
+
"error_code": "EXPORT_CELLS_LIMIT_EXCEEDED",
|
|
1037
|
+
"estimated_cells": estimated_cells,
|
|
1038
|
+
"cell_limit": EXPORT_CELL_LIMIT,
|
|
1039
|
+
"include_workflow_log": include_workflow_log,
|
|
1040
|
+
},
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
def _resolve_export_snapshot(
|
|
1044
|
+
self,
|
|
1045
|
+
context,
|
|
1046
|
+
local_job: dict[str, Any],
|
|
1047
|
+
) -> dict[str, Any]: # type: ignore[no-untyped-def]
|
|
1048
|
+
app_key = str(local_job.get("app_key") or "").strip()
|
|
1049
|
+
process_payload = self._lookup_process_details(context, app_key=app_key)
|
|
1050
|
+
history_page = self.backend.request(
|
|
1051
|
+
"GET",
|
|
1052
|
+
context,
|
|
1053
|
+
"/app/apply/dataExport/record",
|
|
1054
|
+
params={"appKey": app_key, "pageNum": 1, "pageSize": 100},
|
|
1055
|
+
)
|
|
1056
|
+
history_records = _extract_export_records(history_page)
|
|
1057
|
+
matched_record, matched_by = _match_export_history_record(history_records, local_job=local_job)
|
|
1058
|
+
if process_payload is not None:
|
|
1059
|
+
normalized_status = _normalize_export_status(process_payload.get("processStatus") or process_payload.get("status"))
|
|
1060
|
+
if normalized_status in {"queued", "running"}:
|
|
1061
|
+
return {
|
|
1062
|
+
"status": normalized_status,
|
|
1063
|
+
"process_status": _coerce_int(process_payload.get("processStatus") or process_payload.get("status")),
|
|
1064
|
+
"num": _coerce_int(process_payload.get("num")),
|
|
1065
|
+
"error_code": process_payload.get("errorCode"),
|
|
1066
|
+
"audit_record_status": process_payload.get("auditRecordStatus"),
|
|
1067
|
+
"file_infos": _normalize_export_file_infos(process_payload.get("fileUrls")),
|
|
1068
|
+
"file_urls": _extract_export_file_urls(process_payload.get("fileUrls")),
|
|
1069
|
+
"file_names": _extract_export_file_names(process_payload.get("fileUrls")),
|
|
1070
|
+
"warnings": [],
|
|
1071
|
+
"verification": {
|
|
1072
|
+
"current_process_visible": True,
|
|
1073
|
+
"history_match_resolved": matched_record is not None,
|
|
1074
|
+
},
|
|
1075
|
+
"message": None,
|
|
1076
|
+
}
|
|
1077
|
+
if matched_record is None:
|
|
1078
|
+
warning_code = "EXPORT_HISTORY_PENDING"
|
|
1079
|
+
warning_message = "export has not appeared in export history yet"
|
|
1080
|
+
if matched_by == "ambiguous":
|
|
1081
|
+
warning_code = "EXPORT_HISTORY_AMBIGUOUS"
|
|
1082
|
+
warning_message = "export result could not be matched uniquely in export history"
|
|
1083
|
+
return {
|
|
1084
|
+
"status": "unknown",
|
|
1085
|
+
"process_status": None,
|
|
1086
|
+
"num": None,
|
|
1087
|
+
"error_code": None,
|
|
1088
|
+
"audit_record_status": None,
|
|
1089
|
+
"file_infos": [],
|
|
1090
|
+
"file_urls": [],
|
|
1091
|
+
"file_names": [],
|
|
1092
|
+
"warnings": [{"code": warning_code, "message": warning_message}],
|
|
1093
|
+
"verification": {
|
|
1094
|
+
"current_process_visible": process_payload is not None,
|
|
1095
|
+
"history_match_resolved": False,
|
|
1096
|
+
},
|
|
1097
|
+
"message": warning_message,
|
|
1098
|
+
}
|
|
1099
|
+
file_infos = _normalize_export_file_infos(matched_record.get("fileUrls"))
|
|
1100
|
+
raw_process_status = _coerce_int(matched_record.get("processStatus"))
|
|
1101
|
+
normalized_status = _normalize_export_status(raw_process_status)
|
|
1102
|
+
message = None
|
|
1103
|
+
if normalized_status == "failed":
|
|
1104
|
+
message = "export failed"
|
|
1105
|
+
return {
|
|
1106
|
+
"status": normalized_status,
|
|
1107
|
+
"process_status": raw_process_status,
|
|
1108
|
+
"num": _coerce_int(matched_record.get("num")),
|
|
1109
|
+
"error_code": matched_record.get("errorCode"),
|
|
1110
|
+
"audit_record_status": matched_record.get("auditRecordStatus"),
|
|
1111
|
+
"file_infos": file_infos,
|
|
1112
|
+
"file_urls": [item.get("url") for item in file_infos if isinstance(item.get("url"), str)],
|
|
1113
|
+
"file_names": [item.get("name") for item in file_infos if isinstance(item.get("name"), str)],
|
|
1114
|
+
"warnings": [],
|
|
1115
|
+
"verification": {
|
|
1116
|
+
"current_process_visible": process_payload is not None,
|
|
1117
|
+
"history_match_resolved": True,
|
|
1118
|
+
"matched_by": matched_by,
|
|
1119
|
+
},
|
|
1120
|
+
"message": message,
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
def _lookup_process_details(self, context, *, app_key: str) -> JSONObject | None: # type: ignore[no-untyped-def]
|
|
1124
|
+
try:
|
|
1125
|
+
payload = self.backend.request(
|
|
1126
|
+
"GET",
|
|
1127
|
+
context,
|
|
1128
|
+
f"/process/{app_key}/details",
|
|
1129
|
+
params={"taskType": 2},
|
|
1130
|
+
)
|
|
1131
|
+
except QingflowApiError as exc:
|
|
1132
|
+
if exc.backend_code in {40002, 40027}:
|
|
1133
|
+
return None
|
|
1134
|
+
raise
|
|
1135
|
+
if isinstance(payload, dict):
|
|
1136
|
+
nested = payload.get("data")
|
|
1137
|
+
if isinstance(nested, dict):
|
|
1138
|
+
payload = nested
|
|
1139
|
+
if isinstance(payload.get("detail"), dict):
|
|
1140
|
+
return cast(JSONObject, payload.get("detail"))
|
|
1141
|
+
if any(key in payload for key in ("processStatus", "progress", "num", "fileUrls", "errorCode")):
|
|
1142
|
+
return cast(JSONObject, payload)
|
|
1143
|
+
return None
|
|
1144
|
+
|
|
1145
|
+
def _status_payload_from_snapshot(
|
|
1146
|
+
self,
|
|
1147
|
+
local_job: dict[str, Any],
|
|
1148
|
+
export_handle: str,
|
|
1149
|
+
snapshot: dict[str, Any],
|
|
1150
|
+
) -> dict[str, Any]:
|
|
1151
|
+
normalized_status = str(snapshot.get("status") or "unknown")
|
|
1152
|
+
ok = normalized_status not in {"failed"} or snapshot.get("error_code") in (None, "", 0)
|
|
1153
|
+
if normalized_status == "failed":
|
|
1154
|
+
ok = False
|
|
1155
|
+
return {
|
|
1156
|
+
"ok": ok,
|
|
1157
|
+
"status": normalized_status,
|
|
1158
|
+
"export_handle": export_handle,
|
|
1159
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
1160
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
1161
|
+
"row_scope": str(local_job.get("row_scope") or "all"),
|
|
1162
|
+
"selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
|
|
1163
|
+
"field_scope": str(local_job.get("field_scope") or "all"),
|
|
1164
|
+
"selected_field_count": _coerce_int(local_job.get("selected_field_count")),
|
|
1165
|
+
"include_workflow_log": bool(local_job.get("include_workflow_log")),
|
|
1166
|
+
"num": snapshot.get("num"),
|
|
1167
|
+
"process_status": snapshot.get("process_status"),
|
|
1168
|
+
"error_code": snapshot.get("error_code"),
|
|
1169
|
+
"audit_record_status": snapshot.get("audit_record_status"),
|
|
1170
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
1171
|
+
"file_names": snapshot.get("file_names") or [],
|
|
1172
|
+
"downloaded_files": [],
|
|
1173
|
+
"warnings": snapshot.get("warnings") or [],
|
|
1174
|
+
"verification": snapshot.get("verification") or {},
|
|
1175
|
+
"message": snapshot.get("message"),
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
def _download_export_files(
|
|
1179
|
+
self,
|
|
1180
|
+
*,
|
|
1181
|
+
file_infos: list[JSONObject],
|
|
1182
|
+
download_to_path: str | None,
|
|
1183
|
+
default_directory: str | None,
|
|
1184
|
+
app_key: str,
|
|
1185
|
+
view_id: str,
|
|
1186
|
+
) -> list[JSONObject]:
|
|
1187
|
+
if download_to_path is None and default_directory is None:
|
|
1188
|
+
return []
|
|
1189
|
+
effective_hint = download_to_path or default_directory
|
|
1190
|
+
assert effective_hint is not None
|
|
1191
|
+
targets = _resolve_download_targets(
|
|
1192
|
+
effective_hint,
|
|
1193
|
+
file_infos=file_infos,
|
|
1194
|
+
app_key=app_key,
|
|
1195
|
+
view_id=view_id,
|
|
1196
|
+
)
|
|
1197
|
+
downloaded_files: list[JSONObject] = []
|
|
1198
|
+
for file_info, target in zip(file_infos, targets, strict=False):
|
|
1199
|
+
url = str(file_info.get("url") or "").strip()
|
|
1200
|
+
if not url:
|
|
1201
|
+
continue
|
|
1202
|
+
content = self.backend.download_binary(url)
|
|
1203
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1204
|
+
target.write_bytes(content)
|
|
1205
|
+
downloaded_files.append(
|
|
1206
|
+
{
|
|
1207
|
+
"file_name": str(file_info.get("name") or target.name),
|
|
1208
|
+
"path": str(target),
|
|
1209
|
+
"url": url,
|
|
1210
|
+
}
|
|
1211
|
+
)
|
|
1212
|
+
return downloaded_files
|
|
1213
|
+
|
|
1214
|
+
def _normalize_timeout_seconds(self, wait_timeout_seconds: float | None) -> float:
|
|
1215
|
+
if wait_timeout_seconds is None:
|
|
1216
|
+
return DEFAULT_EXPORT_TIMEOUT_SECONDS
|
|
1217
|
+
try:
|
|
1218
|
+
value = float(wait_timeout_seconds)
|
|
1219
|
+
except (TypeError, ValueError):
|
|
1220
|
+
return DEFAULT_EXPORT_TIMEOUT_SECONDS
|
|
1221
|
+
return value if value > 0 else DEFAULT_EXPORT_TIMEOUT_SECONDS
|
|
1222
|
+
|
|
1223
|
+
def _failed_export_result(
|
|
1224
|
+
self,
|
|
1225
|
+
*,
|
|
1226
|
+
error_code: str,
|
|
1227
|
+
message: str,
|
|
1228
|
+
extra: dict[str, Any] | None = None,
|
|
1229
|
+
) -> dict[str, Any]:
|
|
1230
|
+
payload = {
|
|
1231
|
+
"ok": False,
|
|
1232
|
+
"status": "failed",
|
|
1233
|
+
"error_code": error_code,
|
|
1234
|
+
"export_handle": None,
|
|
1235
|
+
"app_key": None,
|
|
1236
|
+
"view_id": None,
|
|
1237
|
+
"num": None,
|
|
1238
|
+
"process_status": None,
|
|
1239
|
+
"audit_record_status": None,
|
|
1240
|
+
"file_urls": [],
|
|
1241
|
+
"file_names": [],
|
|
1242
|
+
"downloaded_files": [],
|
|
1243
|
+
"warnings": [],
|
|
1244
|
+
"verification": {},
|
|
1245
|
+
"message": message,
|
|
1246
|
+
}
|
|
1247
|
+
if extra:
|
|
1248
|
+
payload.update(extra)
|
|
1249
|
+
return payload
|
|
1250
|
+
|
|
1251
|
+
def _runtime_error_as_result(
|
|
1252
|
+
self,
|
|
1253
|
+
error: RuntimeError,
|
|
1254
|
+
*,
|
|
1255
|
+
error_code: str,
|
|
1256
|
+
extra: dict[str, Any] | None = None,
|
|
1257
|
+
) -> dict[str, Any]:
|
|
1258
|
+
try:
|
|
1259
|
+
payload = json.loads(str(error))
|
|
1260
|
+
except json.JSONDecodeError:
|
|
1261
|
+
payload = {"message": str(error)}
|
|
1262
|
+
response = self._failed_export_result(
|
|
1263
|
+
error_code=((payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}).get("error_code") or error_code,
|
|
1264
|
+
message=str(payload.get("message") or str(error)),
|
|
1265
|
+
)
|
|
1266
|
+
if extra:
|
|
1267
|
+
response.update(extra)
|
|
1268
|
+
return response
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def _extract_export_records(payload: Any) -> list[JSONObject]:
|
|
1272
|
+
if isinstance(payload, dict):
|
|
1273
|
+
for key in ("list", "records", "items"):
|
|
1274
|
+
value = payload.get(key)
|
|
1275
|
+
if isinstance(value, list):
|
|
1276
|
+
return [item for item in value if isinstance(item, dict)]
|
|
1277
|
+
if isinstance(payload, list):
|
|
1278
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
1279
|
+
return []
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _match_export_history_record(
|
|
1283
|
+
records: list[JSONObject],
|
|
1284
|
+
*,
|
|
1285
|
+
local_job: dict[str, Any],
|
|
1286
|
+
) -> tuple[JSONObject | None, str | None]:
|
|
1287
|
+
uid = _extract_operate_user_uid(local_job)
|
|
1288
|
+
started_at = _parse_utc(local_job.get("started_at"))
|
|
1289
|
+
candidates = records
|
|
1290
|
+
if uid is not None:
|
|
1291
|
+
candidates = [item for item in candidates if _extract_operate_user_uid(item.get("operateUser")) == uid]
|
|
1292
|
+
if started_at is not None:
|
|
1293
|
+
started_at = started_at.replace(microsecond=0)
|
|
1294
|
+
parsed_candidates: list[tuple[JSONObject, datetime]] = []
|
|
1295
|
+
for item in candidates:
|
|
1296
|
+
operate_time = _parse_utc(item.get("operateTime")) or _parse_utc(item.get("operate_time"))
|
|
1297
|
+
if operate_time is None:
|
|
1298
|
+
continue
|
|
1299
|
+
parsed_candidates.append((item, operate_time))
|
|
1300
|
+
exact_or_after = [item for item, operate_time in parsed_candidates if operate_time >= started_at]
|
|
1301
|
+
if len(exact_or_after) == 1:
|
|
1302
|
+
return exact_or_after[0], "operate_user_started_at"
|
|
1303
|
+
if len(exact_or_after) > 1:
|
|
1304
|
+
return None, "ambiguous"
|
|
1305
|
+
skew_tolerant = [
|
|
1306
|
+
item
|
|
1307
|
+
for item, operate_time in parsed_candidates
|
|
1308
|
+
if operate_time >= (started_at - timedelta(seconds=5))
|
|
1309
|
+
]
|
|
1310
|
+
if len(skew_tolerant) == 1:
|
|
1311
|
+
return skew_tolerant[0], "operate_user_started_at_skew"
|
|
1312
|
+
if len(skew_tolerant) > 1:
|
|
1313
|
+
return None, "ambiguous"
|
|
1314
|
+
candidates = []
|
|
1315
|
+
if len(candidates) == 1:
|
|
1316
|
+
return candidates[0], "operate_user_started_at"
|
|
1317
|
+
if len(candidates) > 1:
|
|
1318
|
+
return None, "ambiguous"
|
|
1319
|
+
return None, None
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _normalize_export_status(value: Any) -> str:
|
|
1323
|
+
status_code = _coerce_int(value)
|
|
1324
|
+
if status_code is not None:
|
|
1325
|
+
return EXPORT_STATUS_BY_PROCESS_STATUS.get(status_code, "unknown")
|
|
1326
|
+
text = str(value or "").strip().lower()
|
|
1327
|
+
if text in {"queued", "running", "succeeded", "failed", "unknown"}:
|
|
1328
|
+
return text
|
|
1329
|
+
if text in {"line_up", "lineup"}:
|
|
1330
|
+
return "queued"
|
|
1331
|
+
if text in {"execute", "executing", "processing"}:
|
|
1332
|
+
return "running"
|
|
1333
|
+
if text in {"success", "completed"}:
|
|
1334
|
+
return "succeeded"
|
|
1335
|
+
if text in {"fail", "partly_fail", "partial_fail"}:
|
|
1336
|
+
return "failed"
|
|
1337
|
+
return "unknown"
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _normalize_export_file_infos(value: Any) -> list[JSONObject]:
|
|
1341
|
+
if not isinstance(value, list):
|
|
1342
|
+
return []
|
|
1343
|
+
items: list[JSONObject] = []
|
|
1344
|
+
for item in value:
|
|
1345
|
+
if not isinstance(item, dict):
|
|
1346
|
+
continue
|
|
1347
|
+
url = str(item.get("url") or "").strip()
|
|
1348
|
+
name = str(item.get("name") or item.get("fileName") or "").strip()
|
|
1349
|
+
payload: JSONObject = {}
|
|
1350
|
+
if url:
|
|
1351
|
+
payload["url"] = url
|
|
1352
|
+
if name:
|
|
1353
|
+
payload["name"] = name
|
|
1354
|
+
if payload:
|
|
1355
|
+
items.append(payload)
|
|
1356
|
+
return items
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _extract_export_file_urls(value: Any) -> list[str]:
|
|
1360
|
+
return [str(item.get("url") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("url") or "").strip()]
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _extract_export_file_names(value: Any) -> list[str]:
|
|
1364
|
+
return [str(item.get("name") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("name") or "").strip()]
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _extract_operate_user_uid(value: Any) -> int | None:
|
|
1368
|
+
if isinstance(value, dict):
|
|
1369
|
+
for key in ("uid", "userId", "id"):
|
|
1370
|
+
uid = _coerce_int(value.get(key))
|
|
1371
|
+
if uid is not None:
|
|
1372
|
+
return uid
|
|
1373
|
+
return _coerce_int(value)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _parse_utc(value: Any) -> datetime | None:
|
|
1377
|
+
text = str(value or "").strip()
|
|
1378
|
+
if not text:
|
|
1379
|
+
return None
|
|
1380
|
+
normalized = text.replace("Z", "+00:00")
|
|
1381
|
+
try:
|
|
1382
|
+
parsed = datetime.fromisoformat(normalized)
|
|
1383
|
+
except ValueError:
|
|
1384
|
+
return None
|
|
1385
|
+
if parsed.tzinfo is None:
|
|
1386
|
+
return parsed.replace(tzinfo=LOCAL_TIMEZONE).astimezone(timezone.utc)
|
|
1387
|
+
return parsed.astimezone(timezone.utc)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _coerce_int(value: Any) -> int | None:
|
|
1391
|
+
if value is None or value == "":
|
|
1392
|
+
return None
|
|
1393
|
+
try:
|
|
1394
|
+
return int(value)
|
|
1395
|
+
except (TypeError, ValueError):
|
|
1396
|
+
return None
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def _coerce_positive_int(value: Any) -> int | None:
|
|
1400
|
+
parsed = _coerce_int(value)
|
|
1401
|
+
if parsed is None or parsed <= 0:
|
|
1402
|
+
return None
|
|
1403
|
+
return parsed
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def _extract_export_view_question_entries(questions: Any) -> list[JSONObject]:
|
|
1407
|
+
if not isinstance(questions, list):
|
|
1408
|
+
return []
|
|
1409
|
+
entries: list[JSONObject] = []
|
|
1410
|
+
fallback_order = 0
|
|
1411
|
+
|
|
1412
|
+
def walk(nodes: Any) -> None:
|
|
1413
|
+
nonlocal fallback_order
|
|
1414
|
+
if not isinstance(nodes, list):
|
|
1415
|
+
return
|
|
1416
|
+
for item in nodes:
|
|
1417
|
+
if not isinstance(item, dict):
|
|
1418
|
+
continue
|
|
1419
|
+
children: list[Any] = []
|
|
1420
|
+
for child_key in ("innerQues", "subQues", "innerQuestions", "subQuestions"):
|
|
1421
|
+
child_value = item.get(child_key)
|
|
1422
|
+
if isinstance(child_value, list) and child_value:
|
|
1423
|
+
children.extend(child_value)
|
|
1424
|
+
if children:
|
|
1425
|
+
walk(children)
|
|
1426
|
+
continue
|
|
1427
|
+
field_id = _coerce_int(item.get("queId"))
|
|
1428
|
+
if field_id is None:
|
|
1429
|
+
continue
|
|
1430
|
+
fallback_order += 1
|
|
1431
|
+
downloadable_raw = item.get("beingDownload")
|
|
1432
|
+
entries.append(
|
|
1433
|
+
{
|
|
1434
|
+
"field_id": field_id,
|
|
1435
|
+
"name": str(item.get("queTitle") or "").strip(),
|
|
1436
|
+
"display_order": _coerce_positive_int(item.get("displayOrdinal")) or fallback_order,
|
|
1437
|
+
"downloadable": bool(downloadable_raw) if downloadable_raw is not None else True,
|
|
1438
|
+
}
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
walk(questions)
|
|
1442
|
+
return sorted(
|
|
1443
|
+
entries,
|
|
1444
|
+
key=lambda entry: (
|
|
1445
|
+
_coerce_positive_int(entry.get("display_order")) if _coerce_positive_int(entry.get("display_order")) is not None else 10**9,
|
|
1446
|
+
str(entry.get("name") or ""),
|
|
1447
|
+
),
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _normalize_export_columns(columns: list[JSONObject | int]) -> list[int]:
|
|
1452
|
+
normalized: list[int] = []
|
|
1453
|
+
for field_id in _normalize_public_column_selectors(columns):
|
|
1454
|
+
if field_id not in normalized:
|
|
1455
|
+
normalized.append(field_id)
|
|
1456
|
+
return normalized
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _normalize_export_record_ids(record_ids: list[str | int]) -> list[int]:
|
|
1460
|
+
normalized: list[int] = []
|
|
1461
|
+
seen: set[int] = set()
|
|
1462
|
+
for item in record_ids:
|
|
1463
|
+
record_id = _coerce_int(item)
|
|
1464
|
+
if record_id is None or record_id <= 0 or record_id in seen:
|
|
1465
|
+
continue
|
|
1466
|
+
normalized.append(record_id)
|
|
1467
|
+
seen.add(record_id)
|
|
1468
|
+
return normalized
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _effective_total(page: JSONObject, *, page_size: int) -> int:
|
|
1472
|
+
rows = page.get("list")
|
|
1473
|
+
returned_rows = len(rows) if isinstance(rows, list) else 0
|
|
1474
|
+
reported = _coerce_int(page.get("total"))
|
|
1475
|
+
if reported is None:
|
|
1476
|
+
reported = _coerce_int(page.get("count"))
|
|
1477
|
+
if reported is not None:
|
|
1478
|
+
return max(reported, returned_rows)
|
|
1479
|
+
page_amount = _coerce_int(page.get("pageAmount"))
|
|
1480
|
+
if page_amount is not None:
|
|
1481
|
+
return page_amount * page_size
|
|
1482
|
+
return returned_rows
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _page_has_more(page: JSONObject, *, current_page: int, page_size: int, returned_rows: int) -> bool:
|
|
1486
|
+
page_amount = _coerce_int(page.get("pageAmount"))
|
|
1487
|
+
if page_amount is not None:
|
|
1488
|
+
return current_page < page_amount
|
|
1489
|
+
return returned_rows >= page_size
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _resolve_download_targets(
|
|
1493
|
+
destination_hint: str,
|
|
1494
|
+
*,
|
|
1495
|
+
file_infos: list[JSONObject],
|
|
1496
|
+
app_key: str,
|
|
1497
|
+
view_id: str,
|
|
1498
|
+
) -> list[Path]:
|
|
1499
|
+
path = Path(destination_hint).expanduser()
|
|
1500
|
+
timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
|
|
1501
|
+
if len(file_infos) > 1:
|
|
1502
|
+
if path.exists() and not path.is_dir():
|
|
1503
|
+
raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
|
|
1504
|
+
if not path.exists() and path.suffix:
|
|
1505
|
+
raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
|
|
1506
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
1507
|
+
return [
|
|
1508
|
+
path / _choose_export_file_name(
|
|
1509
|
+
file_info,
|
|
1510
|
+
app_key=app_key,
|
|
1511
|
+
view_id=view_id,
|
|
1512
|
+
timestamp=timestamp,
|
|
1513
|
+
index=index,
|
|
1514
|
+
)
|
|
1515
|
+
for index, file_info in enumerate(file_infos, start=1)
|
|
1516
|
+
]
|
|
1517
|
+
if path.exists() and path.is_dir():
|
|
1518
|
+
return [
|
|
1519
|
+
path / _choose_export_file_name(
|
|
1520
|
+
file_infos[0],
|
|
1521
|
+
app_key=app_key,
|
|
1522
|
+
view_id=view_id,
|
|
1523
|
+
timestamp=timestamp,
|
|
1524
|
+
index=1,
|
|
1525
|
+
)
|
|
1526
|
+
]
|
|
1527
|
+
if path.suffix or path.exists():
|
|
1528
|
+
return [path]
|
|
1529
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
1530
|
+
return [
|
|
1531
|
+
path / _choose_export_file_name(
|
|
1532
|
+
file_infos[0],
|
|
1533
|
+
app_key=app_key,
|
|
1534
|
+
view_id=view_id,
|
|
1535
|
+
timestamp=timestamp,
|
|
1536
|
+
index=1,
|
|
1537
|
+
)
|
|
1538
|
+
]
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _choose_export_file_name(
|
|
1542
|
+
file_info: JSONObject,
|
|
1543
|
+
*,
|
|
1544
|
+
app_key: str,
|
|
1545
|
+
view_id: str,
|
|
1546
|
+
timestamp: str,
|
|
1547
|
+
index: int,
|
|
1548
|
+
) -> str:
|
|
1549
|
+
remote_name = str(file_info.get("name") or "").strip()
|
|
1550
|
+
if remote_name:
|
|
1551
|
+
sanitized = _sanitize_filename(remote_name)
|
|
1552
|
+
if sanitized:
|
|
1553
|
+
return sanitized
|
|
1554
|
+
suffix = ".xlsx"
|
|
1555
|
+
safe_view = _sanitize_filename(view_id.replace(":", "_")) or "view"
|
|
1556
|
+
return f"{_sanitize_filename(app_key) or 'app'}_{safe_view}_{timestamp}_{index}{suffix}"
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def _sanitize_filename(value: str) -> str:
|
|
1560
|
+
base = _SAFE_FILE_CHARS.sub("_", value).strip("._")
|
|
1561
|
+
return base or ""
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _utc_now() -> datetime:
|
|
1565
|
+
return datetime.now(timezone.utc)
|