@josephyan/qingflow-cli 0.2.0-beta.1008 → 0.2.0-beta.1010
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/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/exports.py +64 -0
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +56 -0
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/public_surface.py +4 -0
- package/src/qingflow_mcp/response_trim.py +14 -0
- package/src/qingflow_mcp/server.py +10 -0
- package/src/qingflow_mcp/server_app_user.py +53 -0
- package/src/qingflow_mcp/tools/export_tools.py +960 -0
- package/src/qingflow_mcp/tools/resource_read_tools.py +30 -1
|
@@ -0,0 +1,960 @@
|
|
|
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 ..config import DEFAULT_PROFILE
|
|
15
|
+
from ..errors import QingflowApiError
|
|
16
|
+
from ..export_store import ExportJobStore
|
|
17
|
+
from ..json_types import JSONObject
|
|
18
|
+
from .base import ToolBase, tool_cn_name
|
|
19
|
+
from .record_tools import AccessibleViewRoute, LAYOUT_ONLY_QUE_TYPES, RecordTools
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
EXPORT_STATUS_BY_PROCESS_STATUS = {
|
|
23
|
+
1: "queued",
|
|
24
|
+
2: "running",
|
|
25
|
+
3: "succeeded",
|
|
26
|
+
4: "failed",
|
|
27
|
+
5: "failed",
|
|
28
|
+
}
|
|
29
|
+
DEFAULT_EXPORT_TIMEOUT_SECONDS = 60.0
|
|
30
|
+
EXPORT_POLL_INTERVAL_SECONDS = 1.5
|
|
31
|
+
_SAFE_FILE_CHARS = re.compile(r"[^0-9A-Za-z._-]+")
|
|
32
|
+
LOCAL_TIMEZONE = datetime.now().astimezone().tzinfo or timezone.utc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExportTools(ToolBase):
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
sessions,
|
|
39
|
+
backend,
|
|
40
|
+
*,
|
|
41
|
+
job_store: ExportJobStore | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(sessions, backend)
|
|
44
|
+
self._record_tools = RecordTools(sessions, backend)
|
|
45
|
+
self._job_store = job_store or ExportJobStore()
|
|
46
|
+
|
|
47
|
+
def register(self, mcp: FastMCP) -> None:
|
|
48
|
+
@mcp.tool(description="Start an asynchronous xlsx export for a Qingflow record view and return an opaque export_handle.")
|
|
49
|
+
def record_export_start(
|
|
50
|
+
profile: str = DEFAULT_PROFILE,
|
|
51
|
+
app_key: str = "",
|
|
52
|
+
view_id: str = "system:all",
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
return self.record_export_start(
|
|
55
|
+
profile=profile,
|
|
56
|
+
app_key=app_key,
|
|
57
|
+
view_id=view_id,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@mcp.tool(description="Get asynchronous record export status by opaque export_handle.")
|
|
61
|
+
def record_export_status_get(
|
|
62
|
+
profile: str = DEFAULT_PROFILE,
|
|
63
|
+
export_handle: str = "",
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
return self.record_export_status_get(
|
|
66
|
+
profile=profile,
|
|
67
|
+
export_handle=export_handle,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@mcp.tool(description="Get completed record export result, returning remote file links and optionally downloading files locally.")
|
|
71
|
+
def record_export_get(
|
|
72
|
+
profile: str = DEFAULT_PROFILE,
|
|
73
|
+
export_handle: str = "",
|
|
74
|
+
download_to_path: str | None = None,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
return self.record_export_get(
|
|
77
|
+
profile=profile,
|
|
78
|
+
export_handle=export_handle,
|
|
79
|
+
download_to_path=download_to_path,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@mcp.tool(description="Start a record export, wait for completion, and download the xlsx locally while also returning remote download links.")
|
|
83
|
+
def record_export_direct(
|
|
84
|
+
profile: str = DEFAULT_PROFILE,
|
|
85
|
+
app_key: str = "",
|
|
86
|
+
view_id: str = "system:all",
|
|
87
|
+
download_to_path: str | None = None,
|
|
88
|
+
wait_timeout_seconds: float | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
return self.record_export_direct(
|
|
91
|
+
profile=profile,
|
|
92
|
+
app_key=app_key,
|
|
93
|
+
view_id=view_id,
|
|
94
|
+
download_to_path=download_to_path,
|
|
95
|
+
wait_timeout_seconds=wait_timeout_seconds,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@tool_cn_name("记录导出启动")
|
|
99
|
+
def record_export_start(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
profile: str = DEFAULT_PROFILE,
|
|
103
|
+
app_key: str,
|
|
104
|
+
view_id: str = "system:all",
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
normalized_app_key = str(app_key or "").strip()
|
|
107
|
+
normalized_view_id = str(view_id or "").strip() or "system:all"
|
|
108
|
+
if not normalized_app_key:
|
|
109
|
+
return self._failed_export_result(
|
|
110
|
+
error_code="EXPORT_START_FAILED",
|
|
111
|
+
message="app_key is required",
|
|
112
|
+
extra={"view_id": normalized_view_id, "status": "failed"},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def runner(session_profile, context):
|
|
116
|
+
resolved_view, compatibility_warnings = self._record_tools._resolve_accessible_view_route(
|
|
117
|
+
profile,
|
|
118
|
+
context,
|
|
119
|
+
normalized_app_key,
|
|
120
|
+
view_id=normalized_view_id,
|
|
121
|
+
list_type=None,
|
|
122
|
+
view_key=None,
|
|
123
|
+
view_name=None,
|
|
124
|
+
allow_default=True,
|
|
125
|
+
)
|
|
126
|
+
export_config, export_config_warnings = self._build_export_config(
|
|
127
|
+
profile=profile,
|
|
128
|
+
context=context,
|
|
129
|
+
app_key=normalized_app_key,
|
|
130
|
+
resolved_view=resolved_view,
|
|
131
|
+
)
|
|
132
|
+
filter_bean = self._build_export_filter_bean(resolved_view)
|
|
133
|
+
started_at = _utc_now().replace(microsecond=0).isoformat()
|
|
134
|
+
socket_result = self.backend.start_socket_record_export(
|
|
135
|
+
context,
|
|
136
|
+
app_key=normalized_app_key,
|
|
137
|
+
view_id=resolved_view.view_id,
|
|
138
|
+
view_key=resolved_view.view_selection.view_key if resolved_view.view_selection is not None else None,
|
|
139
|
+
filter_bean=filter_bean,
|
|
140
|
+
export_config=export_config,
|
|
141
|
+
)
|
|
142
|
+
export_handle = uuid4().hex
|
|
143
|
+
self._job_store.put(
|
|
144
|
+
export_handle,
|
|
145
|
+
{
|
|
146
|
+
"created_at": started_at,
|
|
147
|
+
"profile": profile,
|
|
148
|
+
"app_key": normalized_app_key,
|
|
149
|
+
"view_id": resolved_view.view_id,
|
|
150
|
+
"backend_export_id": str(socket_result.get("backend_export_id") or ""),
|
|
151
|
+
"started_at": started_at,
|
|
152
|
+
"uid": session_profile.uid,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
warnings = [
|
|
156
|
+
*deepcopy(compatibility_warnings),
|
|
157
|
+
*deepcopy(export_config_warnings),
|
|
158
|
+
*deepcopy(cast(list[JSONObject], socket_result.get("warnings") or [])),
|
|
159
|
+
]
|
|
160
|
+
return {
|
|
161
|
+
"ok": True,
|
|
162
|
+
"status": "accepted",
|
|
163
|
+
"app_key": normalized_app_key,
|
|
164
|
+
"view_id": resolved_view.view_id,
|
|
165
|
+
"export_handle": export_handle,
|
|
166
|
+
"file_urls": [],
|
|
167
|
+
"file_names": [],
|
|
168
|
+
"downloaded_files": [],
|
|
169
|
+
"warnings": warnings,
|
|
170
|
+
"verification": {
|
|
171
|
+
"export_acknowledged": bool(socket_result.get("backend_export_id")),
|
|
172
|
+
"view_route_supported": True,
|
|
173
|
+
},
|
|
174
|
+
"request_route": self.backend.describe_route(context),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
return self._run(profile, runner)
|
|
179
|
+
except RuntimeError as exc:
|
|
180
|
+
return self._runtime_error_as_result(
|
|
181
|
+
exc,
|
|
182
|
+
error_code="EXPORT_START_FAILED",
|
|
183
|
+
extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@tool_cn_name("记录导出状态")
|
|
187
|
+
def record_export_status_get(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
profile: str = DEFAULT_PROFILE,
|
|
191
|
+
export_handle: str,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
normalized_handle = str(export_handle or "").strip()
|
|
194
|
+
if not normalized_handle:
|
|
195
|
+
return self._failed_export_result(
|
|
196
|
+
error_code="CONFIG_ERROR",
|
|
197
|
+
message="export_handle is required",
|
|
198
|
+
extra={"status": "failed"},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def runner(_session_profile, context):
|
|
202
|
+
local_job = self._job_store.get(normalized_handle)
|
|
203
|
+
if local_job is None:
|
|
204
|
+
return self._failed_export_result(
|
|
205
|
+
error_code="EXPORT_HANDLE_UNKNOWN",
|
|
206
|
+
message="export_handle is missing or expired",
|
|
207
|
+
extra={"export_handle": normalized_handle, "status": "failed"},
|
|
208
|
+
)
|
|
209
|
+
snapshot = self._resolve_export_snapshot(context, local_job)
|
|
210
|
+
return self._status_payload_from_snapshot(local_job, normalized_handle, snapshot)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
return self._run(profile, runner)
|
|
214
|
+
except RuntimeError as exc:
|
|
215
|
+
return self._runtime_error_as_result(
|
|
216
|
+
exc,
|
|
217
|
+
error_code="EXPORT_STATUS_FAILED",
|
|
218
|
+
extra={"export_handle": normalized_handle},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
@tool_cn_name("记录导出结果")
|
|
222
|
+
def record_export_get(
|
|
223
|
+
self,
|
|
224
|
+
*,
|
|
225
|
+
profile: str = DEFAULT_PROFILE,
|
|
226
|
+
export_handle: str,
|
|
227
|
+
download_to_path: str | None = None,
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
normalized_handle = str(export_handle or "").strip()
|
|
230
|
+
if not normalized_handle:
|
|
231
|
+
return self._failed_export_result(
|
|
232
|
+
error_code="CONFIG_ERROR",
|
|
233
|
+
message="export_handle is required",
|
|
234
|
+
extra={"status": "failed"},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def runner(_session_profile, context):
|
|
238
|
+
local_job = self._job_store.get(normalized_handle)
|
|
239
|
+
if local_job is None:
|
|
240
|
+
return self._failed_export_result(
|
|
241
|
+
error_code="EXPORT_HANDLE_UNKNOWN",
|
|
242
|
+
message="export_handle is missing or expired",
|
|
243
|
+
extra={"export_handle": normalized_handle, "status": "failed"},
|
|
244
|
+
)
|
|
245
|
+
snapshot = self._resolve_export_snapshot(context, local_job)
|
|
246
|
+
normalized_status = str(snapshot.get("status") or "unknown")
|
|
247
|
+
if normalized_status not in {"succeeded", "failed"}:
|
|
248
|
+
return self._failed_export_result(
|
|
249
|
+
error_code="EXPORT_NOT_READY",
|
|
250
|
+
message="export is not ready yet",
|
|
251
|
+
extra={
|
|
252
|
+
"status": "blocked",
|
|
253
|
+
"export_handle": normalized_handle,
|
|
254
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
255
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
256
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
257
|
+
"file_names": snapshot.get("file_names") or [],
|
|
258
|
+
"downloaded_files": [],
|
|
259
|
+
"warnings": snapshot.get("warnings") or [],
|
|
260
|
+
"verification": snapshot.get("verification") or {},
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
if normalized_status == "failed":
|
|
264
|
+
return {
|
|
265
|
+
**self._failed_export_result(
|
|
266
|
+
error_code=str(snapshot.get("error_code") or "EXPORT_FAILED"),
|
|
267
|
+
message=str(snapshot.get("message") or "export failed"),
|
|
268
|
+
extra={
|
|
269
|
+
"status": "failed",
|
|
270
|
+
"export_handle": normalized_handle,
|
|
271
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
272
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
273
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
274
|
+
"file_names": snapshot.get("file_names") or [],
|
|
275
|
+
"downloaded_files": [],
|
|
276
|
+
"warnings": snapshot.get("warnings") or [],
|
|
277
|
+
"verification": snapshot.get("verification") or {},
|
|
278
|
+
"num": snapshot.get("num"),
|
|
279
|
+
"process_status": snapshot.get("process_status"),
|
|
280
|
+
"audit_record_status": snapshot.get("audit_record_status"),
|
|
281
|
+
},
|
|
282
|
+
),
|
|
283
|
+
}
|
|
284
|
+
file_infos = cast(list[JSONObject], snapshot.get("file_infos") or [])
|
|
285
|
+
if not file_infos:
|
|
286
|
+
return self._failed_export_result(
|
|
287
|
+
error_code="EXPORT_FILE_UNAVAILABLE",
|
|
288
|
+
message="export completed but did not return downloadable files",
|
|
289
|
+
extra={
|
|
290
|
+
"status": "failed",
|
|
291
|
+
"export_handle": normalized_handle,
|
|
292
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
293
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
294
|
+
"warnings": snapshot.get("warnings") or [],
|
|
295
|
+
"verification": snapshot.get("verification") or {},
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
downloaded_files = self._download_export_files(
|
|
299
|
+
file_infos=file_infos,
|
|
300
|
+
download_to_path=download_to_path,
|
|
301
|
+
default_directory=None,
|
|
302
|
+
app_key=str(local_job.get("app_key") or ""),
|
|
303
|
+
view_id=str(local_job.get("view_id") or ""),
|
|
304
|
+
)
|
|
305
|
+
return {
|
|
306
|
+
"ok": True,
|
|
307
|
+
"status": "succeeded",
|
|
308
|
+
"export_handle": normalized_handle,
|
|
309
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
310
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
311
|
+
"num": snapshot.get("num"),
|
|
312
|
+
"process_status": snapshot.get("process_status"),
|
|
313
|
+
"error_code": snapshot.get("error_code"),
|
|
314
|
+
"audit_record_status": snapshot.get("audit_record_status"),
|
|
315
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
316
|
+
"file_names": snapshot.get("file_names") or [],
|
|
317
|
+
"downloaded_files": downloaded_files,
|
|
318
|
+
"warnings": snapshot.get("warnings") or [],
|
|
319
|
+
"verification": snapshot.get("verification") or {},
|
|
320
|
+
"request_route": self.backend.describe_route(context),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
return self._run(profile, runner)
|
|
325
|
+
except RuntimeError as exc:
|
|
326
|
+
return self._runtime_error_as_result(
|
|
327
|
+
exc,
|
|
328
|
+
error_code="EXPORT_GET_FAILED",
|
|
329
|
+
extra={"export_handle": normalized_handle},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
@tool_cn_name("记录直接导出")
|
|
333
|
+
def record_export_direct(
|
|
334
|
+
self,
|
|
335
|
+
*,
|
|
336
|
+
profile: str = DEFAULT_PROFILE,
|
|
337
|
+
app_key: str,
|
|
338
|
+
view_id: str = "system:all",
|
|
339
|
+
download_to_path: str | None = None,
|
|
340
|
+
wait_timeout_seconds: float | None = None,
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
normalized_app_key = str(app_key or "").strip()
|
|
343
|
+
normalized_view_id = str(view_id or "").strip() or "system:all"
|
|
344
|
+
if not normalized_app_key:
|
|
345
|
+
return self._failed_export_result(
|
|
346
|
+
error_code="EXPORT_START_FAILED",
|
|
347
|
+
message="app_key is required",
|
|
348
|
+
extra={"status": "failed", "view_id": normalized_view_id},
|
|
349
|
+
)
|
|
350
|
+
timeout_seconds = self._normalize_timeout_seconds(wait_timeout_seconds)
|
|
351
|
+
|
|
352
|
+
def runner(_session_profile, context):
|
|
353
|
+
start_result = self.record_export_start(
|
|
354
|
+
profile=profile,
|
|
355
|
+
app_key=normalized_app_key,
|
|
356
|
+
view_id=normalized_view_id,
|
|
357
|
+
)
|
|
358
|
+
if not bool(start_result.get("ok")):
|
|
359
|
+
return start_result
|
|
360
|
+
export_handle = str(start_result.get("export_handle") or "")
|
|
361
|
+
deadline = monotonic() + timeout_seconds
|
|
362
|
+
last_snapshot: dict[str, Any] | None = None
|
|
363
|
+
while monotonic() < deadline:
|
|
364
|
+
local_job = self._job_store.get(export_handle)
|
|
365
|
+
if local_job is None:
|
|
366
|
+
return self._failed_export_result(
|
|
367
|
+
error_code="EXPORT_HANDLE_UNKNOWN",
|
|
368
|
+
message="export_handle is missing or expired",
|
|
369
|
+
extra={"status": "failed", "export_handle": export_handle},
|
|
370
|
+
)
|
|
371
|
+
snapshot = self._resolve_export_snapshot(context, local_job)
|
|
372
|
+
last_snapshot = snapshot
|
|
373
|
+
normalized_status = str(snapshot.get("status") or "unknown")
|
|
374
|
+
if normalized_status == "succeeded":
|
|
375
|
+
effective_download_path = download_to_path or str(Path.cwd())
|
|
376
|
+
get_result = self.record_export_get(
|
|
377
|
+
profile=profile,
|
|
378
|
+
export_handle=export_handle,
|
|
379
|
+
download_to_path=effective_download_path,
|
|
380
|
+
)
|
|
381
|
+
if bool(get_result.get("ok")):
|
|
382
|
+
return get_result
|
|
383
|
+
return {
|
|
384
|
+
**get_result,
|
|
385
|
+
"export_handle": export_handle,
|
|
386
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
387
|
+
"file_names": snapshot.get("file_names") or [],
|
|
388
|
+
}
|
|
389
|
+
if normalized_status == "failed":
|
|
390
|
+
return {
|
|
391
|
+
"ok": False,
|
|
392
|
+
"status": "failed",
|
|
393
|
+
"error_code": str(snapshot.get("error_code") or "EXPORT_FAILED"),
|
|
394
|
+
"message": str(snapshot.get("message") or "export failed"),
|
|
395
|
+
"export_handle": export_handle,
|
|
396
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
397
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
398
|
+
"num": snapshot.get("num"),
|
|
399
|
+
"process_status": snapshot.get("process_status"),
|
|
400
|
+
"audit_record_status": snapshot.get("audit_record_status"),
|
|
401
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
402
|
+
"file_names": snapshot.get("file_names") or [],
|
|
403
|
+
"downloaded_files": [],
|
|
404
|
+
"warnings": snapshot.get("warnings") or [],
|
|
405
|
+
"verification": snapshot.get("verification") or {},
|
|
406
|
+
}
|
|
407
|
+
if normalized_status == "unknown":
|
|
408
|
+
warning_codes = {
|
|
409
|
+
str(item.get("code") or "")
|
|
410
|
+
for item in cast(list[JSONObject], snapshot.get("warnings") or [])
|
|
411
|
+
if isinstance(item, dict)
|
|
412
|
+
}
|
|
413
|
+
if "EXPORT_HISTORY_AMBIGUOUS" in warning_codes:
|
|
414
|
+
return {
|
|
415
|
+
"ok": True,
|
|
416
|
+
"status": "unknown",
|
|
417
|
+
"export_handle": export_handle,
|
|
418
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
419
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
420
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
421
|
+
"file_names": snapshot.get("file_names") or [],
|
|
422
|
+
"downloaded_files": [],
|
|
423
|
+
"warnings": snapshot.get("warnings") or [],
|
|
424
|
+
"verification": snapshot.get("verification") or {},
|
|
425
|
+
"message": "export result could not be matched uniquely",
|
|
426
|
+
}
|
|
427
|
+
remaining = deadline - monotonic()
|
|
428
|
+
if remaining <= 0:
|
|
429
|
+
break
|
|
430
|
+
sleep(min(EXPORT_POLL_INTERVAL_SECONDS, remaining))
|
|
431
|
+
timeout_status = "running"
|
|
432
|
+
if isinstance(last_snapshot, dict) and str(last_snapshot.get("status") or "").strip():
|
|
433
|
+
timeout_status = str(last_snapshot.get("status") or timeout_status)
|
|
434
|
+
warnings = list(cast(list[JSONObject], start_result.get("warnings") or []))
|
|
435
|
+
warnings.append(
|
|
436
|
+
{
|
|
437
|
+
"code": "EXPORT_WAIT_TIMEOUT",
|
|
438
|
+
"message": "export is still running; reuse export_handle with record_export_status_get or record_export_get",
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
return {
|
|
442
|
+
"ok": True,
|
|
443
|
+
"status": timeout_status,
|
|
444
|
+
"export_handle": str(start_result.get("export_handle") or ""),
|
|
445
|
+
"app_key": normalized_app_key,
|
|
446
|
+
"view_id": str(start_result.get("view_id") or normalized_view_id),
|
|
447
|
+
"file_urls": (last_snapshot or {}).get("file_urls") or [],
|
|
448
|
+
"file_names": (last_snapshot or {}).get("file_names") or [],
|
|
449
|
+
"downloaded_files": [],
|
|
450
|
+
"warnings": warnings,
|
|
451
|
+
"verification": (last_snapshot or {}).get("verification") or start_result.get("verification") or {},
|
|
452
|
+
"message": "export did not finish before wait_timeout_seconds",
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
return self._run(profile, runner)
|
|
457
|
+
except RuntimeError as exc:
|
|
458
|
+
return self._runtime_error_as_result(
|
|
459
|
+
exc,
|
|
460
|
+
error_code="EXPORT_DIRECT_FAILED",
|
|
461
|
+
extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _build_export_filter_bean(self, resolved_view: AccessibleViewRoute) -> JSONObject:
|
|
465
|
+
filter_payload: JSONObject = {}
|
|
466
|
+
if resolved_view.kind == "system" and resolved_view.list_type is not None:
|
|
467
|
+
filter_payload["type"] = resolved_view.list_type
|
|
468
|
+
return {
|
|
469
|
+
"filter": filter_payload,
|
|
470
|
+
# Backend export code later auto-unboxes this field to primitive boolean.
|
|
471
|
+
# Sending explicit false avoids a null -> NPE -> 41100 failure path.
|
|
472
|
+
"auditRecordStatus": False,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
def _build_export_config(
|
|
476
|
+
self,
|
|
477
|
+
*,
|
|
478
|
+
profile: str,
|
|
479
|
+
context,
|
|
480
|
+
app_key: str,
|
|
481
|
+
resolved_view: AccessibleViewRoute,
|
|
482
|
+
) -> tuple[JSONObject, list[JSONObject]]: # type: ignore[no-untyped-def]
|
|
483
|
+
browse_scope = self._record_tools._build_browse_write_scope(
|
|
484
|
+
profile,
|
|
485
|
+
context,
|
|
486
|
+
app_key,
|
|
487
|
+
resolved_view,
|
|
488
|
+
force_refresh=False,
|
|
489
|
+
)
|
|
490
|
+
index = browse_scope["index"]
|
|
491
|
+
visible_question_ids = cast(set[int], browse_scope.get("visible_question_ids") or set())
|
|
492
|
+
ordered_visible_fields = [
|
|
493
|
+
field
|
|
494
|
+
for field in self._record_tools._schema_fields_for_mode(
|
|
495
|
+
profile,
|
|
496
|
+
context,
|
|
497
|
+
app_key,
|
|
498
|
+
index,
|
|
499
|
+
schema_mode="browse",
|
|
500
|
+
resolved_view=resolved_view,
|
|
501
|
+
)
|
|
502
|
+
if field.que_id in visible_question_ids and field.que_type not in LAYOUT_ONLY_QUE_TYPES
|
|
503
|
+
]
|
|
504
|
+
if not ordered_visible_fields:
|
|
505
|
+
ordered_visible_fields = [
|
|
506
|
+
field
|
|
507
|
+
for field in cast(Any, index).by_id.values()
|
|
508
|
+
if field.que_type not in LAYOUT_ONLY_QUE_TYPES
|
|
509
|
+
]
|
|
510
|
+
question_export_config_list: list[JSONObject] = []
|
|
511
|
+
warnings: list[JSONObject] = []
|
|
512
|
+
for field in ordered_visible_fields:
|
|
513
|
+
if field.que_type is None:
|
|
514
|
+
warnings.append(
|
|
515
|
+
{
|
|
516
|
+
"code": "EXPORT_FIELD_TYPE_UNAVAILABLE",
|
|
517
|
+
"message": f"Skipped field '{field.que_title}' because its field type could not be resolved.",
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
continue
|
|
521
|
+
question_export_config_list.append(
|
|
522
|
+
{
|
|
523
|
+
"queId": field.que_id,
|
|
524
|
+
"queTitle": field.que_title,
|
|
525
|
+
"queType": field.que_type,
|
|
526
|
+
"exportStyle": "default",
|
|
527
|
+
}
|
|
528
|
+
)
|
|
529
|
+
if not question_export_config_list:
|
|
530
|
+
raise QingflowApiError.config_error(
|
|
531
|
+
"record export could not determine exportable fields for the selected view",
|
|
532
|
+
details={"error_code": "EXPORT_CONFIG_UNAVAILABLE"},
|
|
533
|
+
)
|
|
534
|
+
return {"questionExportConfigList": question_export_config_list}, warnings
|
|
535
|
+
|
|
536
|
+
def _resolve_export_snapshot(
|
|
537
|
+
self,
|
|
538
|
+
context,
|
|
539
|
+
local_job: dict[str, Any],
|
|
540
|
+
) -> dict[str, Any]: # type: ignore[no-untyped-def]
|
|
541
|
+
app_key = str(local_job.get("app_key") or "").strip()
|
|
542
|
+
process_payload = self._lookup_process_details(context, app_key=app_key)
|
|
543
|
+
history_page = self.backend.request(
|
|
544
|
+
"GET",
|
|
545
|
+
context,
|
|
546
|
+
"/app/apply/dataExport/record",
|
|
547
|
+
params={"appKey": app_key, "pageNum": 1, "pageSize": 100},
|
|
548
|
+
)
|
|
549
|
+
history_records = _extract_export_records(history_page)
|
|
550
|
+
matched_record, matched_by = _match_export_history_record(history_records, local_job=local_job)
|
|
551
|
+
if process_payload is not None:
|
|
552
|
+
normalized_status = _normalize_export_status(process_payload.get("processStatus") or process_payload.get("status"))
|
|
553
|
+
if normalized_status in {"queued", "running"}:
|
|
554
|
+
return {
|
|
555
|
+
"status": normalized_status,
|
|
556
|
+
"process_status": _coerce_int(process_payload.get("processStatus") or process_payload.get("status")),
|
|
557
|
+
"num": _coerce_int(process_payload.get("num")),
|
|
558
|
+
"error_code": process_payload.get("errorCode"),
|
|
559
|
+
"audit_record_status": process_payload.get("auditRecordStatus"),
|
|
560
|
+
"file_infos": _normalize_export_file_infos(process_payload.get("fileUrls")),
|
|
561
|
+
"file_urls": _extract_export_file_urls(process_payload.get("fileUrls")),
|
|
562
|
+
"file_names": _extract_export_file_names(process_payload.get("fileUrls")),
|
|
563
|
+
"warnings": [],
|
|
564
|
+
"verification": {
|
|
565
|
+
"current_process_visible": True,
|
|
566
|
+
"history_match_resolved": matched_record is not None,
|
|
567
|
+
},
|
|
568
|
+
"message": None,
|
|
569
|
+
}
|
|
570
|
+
if matched_record is None:
|
|
571
|
+
warning_code = "EXPORT_HISTORY_PENDING"
|
|
572
|
+
warning_message = "export has not appeared in export history yet"
|
|
573
|
+
if matched_by == "ambiguous":
|
|
574
|
+
warning_code = "EXPORT_HISTORY_AMBIGUOUS"
|
|
575
|
+
warning_message = "export result could not be matched uniquely in export history"
|
|
576
|
+
return {
|
|
577
|
+
"status": "unknown",
|
|
578
|
+
"process_status": None,
|
|
579
|
+
"num": None,
|
|
580
|
+
"error_code": None,
|
|
581
|
+
"audit_record_status": None,
|
|
582
|
+
"file_infos": [],
|
|
583
|
+
"file_urls": [],
|
|
584
|
+
"file_names": [],
|
|
585
|
+
"warnings": [{"code": warning_code, "message": warning_message}],
|
|
586
|
+
"verification": {
|
|
587
|
+
"current_process_visible": process_payload is not None,
|
|
588
|
+
"history_match_resolved": False,
|
|
589
|
+
},
|
|
590
|
+
"message": warning_message,
|
|
591
|
+
}
|
|
592
|
+
file_infos = _normalize_export_file_infos(matched_record.get("fileUrls"))
|
|
593
|
+
raw_process_status = _coerce_int(matched_record.get("processStatus"))
|
|
594
|
+
normalized_status = _normalize_export_status(raw_process_status)
|
|
595
|
+
message = None
|
|
596
|
+
if normalized_status == "failed":
|
|
597
|
+
message = "export failed"
|
|
598
|
+
return {
|
|
599
|
+
"status": normalized_status,
|
|
600
|
+
"process_status": raw_process_status,
|
|
601
|
+
"num": _coerce_int(matched_record.get("num")),
|
|
602
|
+
"error_code": matched_record.get("errorCode"),
|
|
603
|
+
"audit_record_status": matched_record.get("auditRecordStatus"),
|
|
604
|
+
"file_infos": file_infos,
|
|
605
|
+
"file_urls": [item.get("url") for item in file_infos if isinstance(item.get("url"), str)],
|
|
606
|
+
"file_names": [item.get("name") for item in file_infos if isinstance(item.get("name"), str)],
|
|
607
|
+
"warnings": [],
|
|
608
|
+
"verification": {
|
|
609
|
+
"current_process_visible": process_payload is not None,
|
|
610
|
+
"history_match_resolved": True,
|
|
611
|
+
"matched_by": matched_by,
|
|
612
|
+
},
|
|
613
|
+
"message": message,
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
def _lookup_process_details(self, context, *, app_key: str) -> JSONObject | None: # type: ignore[no-untyped-def]
|
|
617
|
+
try:
|
|
618
|
+
payload = self.backend.request(
|
|
619
|
+
"GET",
|
|
620
|
+
context,
|
|
621
|
+
f"/process/{app_key}/details",
|
|
622
|
+
params={"taskType": 2},
|
|
623
|
+
)
|
|
624
|
+
except QingflowApiError as exc:
|
|
625
|
+
if exc.backend_code in {40002, 40027}:
|
|
626
|
+
return None
|
|
627
|
+
raise
|
|
628
|
+
if isinstance(payload, dict):
|
|
629
|
+
nested = payload.get("data")
|
|
630
|
+
if isinstance(nested, dict):
|
|
631
|
+
payload = nested
|
|
632
|
+
if isinstance(payload.get("detail"), dict):
|
|
633
|
+
return cast(JSONObject, payload.get("detail"))
|
|
634
|
+
if any(key in payload for key in ("processStatus", "progress", "num", "fileUrls", "errorCode")):
|
|
635
|
+
return cast(JSONObject, payload)
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
def _status_payload_from_snapshot(
|
|
639
|
+
self,
|
|
640
|
+
local_job: dict[str, Any],
|
|
641
|
+
export_handle: str,
|
|
642
|
+
snapshot: dict[str, Any],
|
|
643
|
+
) -> dict[str, Any]:
|
|
644
|
+
normalized_status = str(snapshot.get("status") or "unknown")
|
|
645
|
+
ok = normalized_status not in {"failed"} or snapshot.get("error_code") in (None, "", 0)
|
|
646
|
+
if normalized_status == "failed":
|
|
647
|
+
ok = False
|
|
648
|
+
return {
|
|
649
|
+
"ok": ok,
|
|
650
|
+
"status": normalized_status,
|
|
651
|
+
"export_handle": export_handle,
|
|
652
|
+
"app_key": str(local_job.get("app_key") or ""),
|
|
653
|
+
"view_id": str(local_job.get("view_id") or ""),
|
|
654
|
+
"num": snapshot.get("num"),
|
|
655
|
+
"process_status": snapshot.get("process_status"),
|
|
656
|
+
"error_code": snapshot.get("error_code"),
|
|
657
|
+
"audit_record_status": snapshot.get("audit_record_status"),
|
|
658
|
+
"file_urls": snapshot.get("file_urls") or [],
|
|
659
|
+
"file_names": snapshot.get("file_names") or [],
|
|
660
|
+
"downloaded_files": [],
|
|
661
|
+
"warnings": snapshot.get("warnings") or [],
|
|
662
|
+
"verification": snapshot.get("verification") or {},
|
|
663
|
+
"message": snapshot.get("message"),
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
def _download_export_files(
|
|
667
|
+
self,
|
|
668
|
+
*,
|
|
669
|
+
file_infos: list[JSONObject],
|
|
670
|
+
download_to_path: str | None,
|
|
671
|
+
default_directory: str | None,
|
|
672
|
+
app_key: str,
|
|
673
|
+
view_id: str,
|
|
674
|
+
) -> list[JSONObject]:
|
|
675
|
+
if download_to_path is None and default_directory is None:
|
|
676
|
+
return []
|
|
677
|
+
effective_hint = download_to_path or default_directory
|
|
678
|
+
assert effective_hint is not None
|
|
679
|
+
targets = _resolve_download_targets(
|
|
680
|
+
effective_hint,
|
|
681
|
+
file_infos=file_infos,
|
|
682
|
+
app_key=app_key,
|
|
683
|
+
view_id=view_id,
|
|
684
|
+
)
|
|
685
|
+
downloaded_files: list[JSONObject] = []
|
|
686
|
+
for file_info, target in zip(file_infos, targets, strict=False):
|
|
687
|
+
url = str(file_info.get("url") or "").strip()
|
|
688
|
+
if not url:
|
|
689
|
+
continue
|
|
690
|
+
content = self.backend.download_binary(url)
|
|
691
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
692
|
+
target.write_bytes(content)
|
|
693
|
+
downloaded_files.append(
|
|
694
|
+
{
|
|
695
|
+
"file_name": str(file_info.get("name") or target.name),
|
|
696
|
+
"path": str(target),
|
|
697
|
+
"url": url,
|
|
698
|
+
}
|
|
699
|
+
)
|
|
700
|
+
return downloaded_files
|
|
701
|
+
|
|
702
|
+
def _normalize_timeout_seconds(self, wait_timeout_seconds: float | None) -> float:
|
|
703
|
+
if wait_timeout_seconds is None:
|
|
704
|
+
return DEFAULT_EXPORT_TIMEOUT_SECONDS
|
|
705
|
+
try:
|
|
706
|
+
value = float(wait_timeout_seconds)
|
|
707
|
+
except (TypeError, ValueError):
|
|
708
|
+
return DEFAULT_EXPORT_TIMEOUT_SECONDS
|
|
709
|
+
return value if value > 0 else DEFAULT_EXPORT_TIMEOUT_SECONDS
|
|
710
|
+
|
|
711
|
+
def _failed_export_result(
|
|
712
|
+
self,
|
|
713
|
+
*,
|
|
714
|
+
error_code: str,
|
|
715
|
+
message: str,
|
|
716
|
+
extra: dict[str, Any] | None = None,
|
|
717
|
+
) -> dict[str, Any]:
|
|
718
|
+
payload = {
|
|
719
|
+
"ok": False,
|
|
720
|
+
"status": "failed",
|
|
721
|
+
"error_code": error_code,
|
|
722
|
+
"export_handle": None,
|
|
723
|
+
"app_key": None,
|
|
724
|
+
"view_id": None,
|
|
725
|
+
"num": None,
|
|
726
|
+
"process_status": None,
|
|
727
|
+
"audit_record_status": None,
|
|
728
|
+
"file_urls": [],
|
|
729
|
+
"file_names": [],
|
|
730
|
+
"downloaded_files": [],
|
|
731
|
+
"warnings": [],
|
|
732
|
+
"verification": {},
|
|
733
|
+
"message": message,
|
|
734
|
+
}
|
|
735
|
+
if extra:
|
|
736
|
+
payload.update(extra)
|
|
737
|
+
return payload
|
|
738
|
+
|
|
739
|
+
def _runtime_error_as_result(
|
|
740
|
+
self,
|
|
741
|
+
error: RuntimeError,
|
|
742
|
+
*,
|
|
743
|
+
error_code: str,
|
|
744
|
+
extra: dict[str, Any] | None = None,
|
|
745
|
+
) -> dict[str, Any]:
|
|
746
|
+
try:
|
|
747
|
+
payload = json.loads(str(error))
|
|
748
|
+
except json.JSONDecodeError:
|
|
749
|
+
payload = {"message": str(error)}
|
|
750
|
+
response = self._failed_export_result(
|
|
751
|
+
error_code=((payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}).get("error_code") or error_code,
|
|
752
|
+
message=str(payload.get("message") or str(error)),
|
|
753
|
+
)
|
|
754
|
+
if extra:
|
|
755
|
+
response.update(extra)
|
|
756
|
+
return response
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _extract_export_records(payload: Any) -> list[JSONObject]:
|
|
760
|
+
if isinstance(payload, dict):
|
|
761
|
+
for key in ("list", "records", "items"):
|
|
762
|
+
value = payload.get(key)
|
|
763
|
+
if isinstance(value, list):
|
|
764
|
+
return [item for item in value if isinstance(item, dict)]
|
|
765
|
+
if isinstance(payload, list):
|
|
766
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
767
|
+
return []
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _match_export_history_record(
|
|
771
|
+
records: list[JSONObject],
|
|
772
|
+
*,
|
|
773
|
+
local_job: dict[str, Any],
|
|
774
|
+
) -> tuple[JSONObject | None, str | None]:
|
|
775
|
+
uid = _extract_operate_user_uid(local_job)
|
|
776
|
+
started_at = _parse_utc(local_job.get("started_at"))
|
|
777
|
+
candidates = records
|
|
778
|
+
if uid is not None:
|
|
779
|
+
candidates = [item for item in candidates if _extract_operate_user_uid(item.get("operateUser")) == uid]
|
|
780
|
+
if started_at is not None:
|
|
781
|
+
started_at = started_at.replace(microsecond=0)
|
|
782
|
+
parsed_candidates: list[tuple[JSONObject, datetime]] = []
|
|
783
|
+
for item in candidates:
|
|
784
|
+
operate_time = _parse_utc(item.get("operateTime")) or _parse_utc(item.get("operate_time"))
|
|
785
|
+
if operate_time is None:
|
|
786
|
+
continue
|
|
787
|
+
parsed_candidates.append((item, operate_time))
|
|
788
|
+
exact_or_after = [item for item, operate_time in parsed_candidates if operate_time >= started_at]
|
|
789
|
+
if len(exact_or_after) == 1:
|
|
790
|
+
return exact_or_after[0], "operate_user_started_at"
|
|
791
|
+
if len(exact_or_after) > 1:
|
|
792
|
+
return None, "ambiguous"
|
|
793
|
+
skew_tolerant = [
|
|
794
|
+
item
|
|
795
|
+
for item, operate_time in parsed_candidates
|
|
796
|
+
if operate_time >= (started_at - timedelta(seconds=5))
|
|
797
|
+
]
|
|
798
|
+
if len(skew_tolerant) == 1:
|
|
799
|
+
return skew_tolerant[0], "operate_user_started_at_skew"
|
|
800
|
+
if len(skew_tolerant) > 1:
|
|
801
|
+
return None, "ambiguous"
|
|
802
|
+
candidates = []
|
|
803
|
+
if len(candidates) == 1:
|
|
804
|
+
return candidates[0], "operate_user_started_at"
|
|
805
|
+
if len(candidates) > 1:
|
|
806
|
+
return None, "ambiguous"
|
|
807
|
+
return None, None
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _normalize_export_status(value: Any) -> str:
|
|
811
|
+
status_code = _coerce_int(value)
|
|
812
|
+
if status_code is not None:
|
|
813
|
+
return EXPORT_STATUS_BY_PROCESS_STATUS.get(status_code, "unknown")
|
|
814
|
+
text = str(value or "").strip().lower()
|
|
815
|
+
if text in {"queued", "running", "succeeded", "failed", "unknown"}:
|
|
816
|
+
return text
|
|
817
|
+
if text in {"line_up", "lineup"}:
|
|
818
|
+
return "queued"
|
|
819
|
+
if text in {"execute", "executing", "processing"}:
|
|
820
|
+
return "running"
|
|
821
|
+
if text in {"success", "completed"}:
|
|
822
|
+
return "succeeded"
|
|
823
|
+
if text in {"fail", "partly_fail", "partial_fail"}:
|
|
824
|
+
return "failed"
|
|
825
|
+
return "unknown"
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _normalize_export_file_infos(value: Any) -> list[JSONObject]:
|
|
829
|
+
if not isinstance(value, list):
|
|
830
|
+
return []
|
|
831
|
+
items: list[JSONObject] = []
|
|
832
|
+
for item in value:
|
|
833
|
+
if not isinstance(item, dict):
|
|
834
|
+
continue
|
|
835
|
+
url = str(item.get("url") or "").strip()
|
|
836
|
+
name = str(item.get("name") or item.get("fileName") or "").strip()
|
|
837
|
+
payload: JSONObject = {}
|
|
838
|
+
if url:
|
|
839
|
+
payload["url"] = url
|
|
840
|
+
if name:
|
|
841
|
+
payload["name"] = name
|
|
842
|
+
if payload:
|
|
843
|
+
items.append(payload)
|
|
844
|
+
return items
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _extract_export_file_urls(value: Any) -> list[str]:
|
|
848
|
+
return [str(item.get("url") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("url") or "").strip()]
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _extract_export_file_names(value: Any) -> list[str]:
|
|
852
|
+
return [str(item.get("name") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("name") or "").strip()]
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _extract_operate_user_uid(value: Any) -> int | None:
|
|
856
|
+
if isinstance(value, dict):
|
|
857
|
+
for key in ("uid", "userId", "id"):
|
|
858
|
+
uid = _coerce_int(value.get(key))
|
|
859
|
+
if uid is not None:
|
|
860
|
+
return uid
|
|
861
|
+
return _coerce_int(value)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _parse_utc(value: Any) -> datetime | None:
|
|
865
|
+
text = str(value or "").strip()
|
|
866
|
+
if not text:
|
|
867
|
+
return None
|
|
868
|
+
normalized = text.replace("Z", "+00:00")
|
|
869
|
+
try:
|
|
870
|
+
parsed = datetime.fromisoformat(normalized)
|
|
871
|
+
except ValueError:
|
|
872
|
+
return None
|
|
873
|
+
if parsed.tzinfo is None:
|
|
874
|
+
return parsed.replace(tzinfo=LOCAL_TIMEZONE).astimezone(timezone.utc)
|
|
875
|
+
return parsed.astimezone(timezone.utc)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _coerce_int(value: Any) -> int | None:
|
|
879
|
+
if value is None or value == "":
|
|
880
|
+
return None
|
|
881
|
+
try:
|
|
882
|
+
return int(value)
|
|
883
|
+
except (TypeError, ValueError):
|
|
884
|
+
return None
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _resolve_download_targets(
|
|
888
|
+
destination_hint: str,
|
|
889
|
+
*,
|
|
890
|
+
file_infos: list[JSONObject],
|
|
891
|
+
app_key: str,
|
|
892
|
+
view_id: str,
|
|
893
|
+
) -> list[Path]:
|
|
894
|
+
path = Path(destination_hint).expanduser()
|
|
895
|
+
timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
|
|
896
|
+
if len(file_infos) > 1:
|
|
897
|
+
if path.exists() and not path.is_dir():
|
|
898
|
+
raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
|
|
899
|
+
if not path.exists() and path.suffix:
|
|
900
|
+
raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
|
|
901
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
902
|
+
return [
|
|
903
|
+
path / _choose_export_file_name(
|
|
904
|
+
file_info,
|
|
905
|
+
app_key=app_key,
|
|
906
|
+
view_id=view_id,
|
|
907
|
+
timestamp=timestamp,
|
|
908
|
+
index=index,
|
|
909
|
+
)
|
|
910
|
+
for index, file_info in enumerate(file_infos, start=1)
|
|
911
|
+
]
|
|
912
|
+
if path.exists() and path.is_dir():
|
|
913
|
+
return [
|
|
914
|
+
path / _choose_export_file_name(
|
|
915
|
+
file_infos[0],
|
|
916
|
+
app_key=app_key,
|
|
917
|
+
view_id=view_id,
|
|
918
|
+
timestamp=timestamp,
|
|
919
|
+
index=1,
|
|
920
|
+
)
|
|
921
|
+
]
|
|
922
|
+
if path.suffix or path.exists():
|
|
923
|
+
return [path]
|
|
924
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
925
|
+
return [
|
|
926
|
+
path / _choose_export_file_name(
|
|
927
|
+
file_infos[0],
|
|
928
|
+
app_key=app_key,
|
|
929
|
+
view_id=view_id,
|
|
930
|
+
timestamp=timestamp,
|
|
931
|
+
index=1,
|
|
932
|
+
)
|
|
933
|
+
]
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _choose_export_file_name(
|
|
937
|
+
file_info: JSONObject,
|
|
938
|
+
*,
|
|
939
|
+
app_key: str,
|
|
940
|
+
view_id: str,
|
|
941
|
+
timestamp: str,
|
|
942
|
+
index: int,
|
|
943
|
+
) -> str:
|
|
944
|
+
remote_name = str(file_info.get("name") or "").strip()
|
|
945
|
+
if remote_name:
|
|
946
|
+
sanitized = _sanitize_filename(remote_name)
|
|
947
|
+
if sanitized:
|
|
948
|
+
return sanitized
|
|
949
|
+
suffix = ".xlsx"
|
|
950
|
+
safe_view = _sanitize_filename(view_id.replace(":", "_")) or "view"
|
|
951
|
+
return f"{_sanitize_filename(app_key) or 'app'}_{safe_view}_{timestamp}_{index}{suffix}"
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _sanitize_filename(value: str) -> str:
|
|
955
|
+
base = _SAFE_FILE_CHARS.sub("_", value).strip("._")
|
|
956
|
+
return base or ""
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _utc_now() -> datetime:
|
|
960
|
+
return datetime.now(timezone.utc)
|