@josephyan/qingflow-app-user-mcp 0.2.0-beta.2 → 0.2.0-beta.21
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 +12 -2
- package/npm/lib/runtime.mjs +37 -0
- package/npm/scripts/postinstall.mjs +5 -1
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/SKILL.md +230 -0
- package/skills/qingflow-app-user/agents/openai.yaml +4 -0
- package/skills/qingflow-app-user/references/data-gotchas.md +49 -0
- package/skills/qingflow-app-user/references/environments.md +63 -0
- package/skills/qingflow-app-user/references/record-patterns.md +110 -0
- package/skills/qingflow-app-user/references/workflow-usage.md +26 -0
- package/skills/qingflow-record-analysis/SKILL.md +253 -0
- package/skills/qingflow-record-analysis/agents/openai.yaml +4 -0
- package/skills/qingflow-record-analysis/references/analysis-gotchas.md +141 -0
- package/skills/qingflow-record-analysis/references/analysis-patterns.md +113 -0
- package/skills/qingflow-record-analysis/references/confidence-reporting.md +92 -0
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +294 -1
- package/src/qingflow_mcp/builder_facade/service.py +2727 -235
- package/src/qingflow_mcp/server.py +7 -5
- package/src/qingflow_mcp/server_app_builder.py +80 -4
- package/src/qingflow_mcp/server_app_user.py +8 -182
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +1 -1
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +21 -2
- package/src/qingflow_mcp/solution/executor.py +34 -7
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1038 -30
- package/src/qingflow_mcp/tools/app_tools.py +1 -2
- package/src/qingflow_mcp/tools/approval_tools.py +357 -75
- package/src/qingflow_mcp/tools/directory_tools.py +158 -28
- package/src/qingflow_mcp/tools/record_tools.py +1954 -973
- package/src/qingflow_mcp/tools/task_tools.py +376 -225
- package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
|
@@ -4,6 +4,7 @@ from copy import deepcopy
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
import tempfile
|
|
8
9
|
from typing import Any
|
|
9
10
|
from uuid import uuid4
|
|
@@ -17,7 +18,9 @@ from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
|
|
|
17
18
|
from ..solution.executor import extract_field_map, _build_viewgraph_questions
|
|
18
19
|
from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
|
|
19
20
|
from ..tools.app_tools import AppTools
|
|
21
|
+
from ..tools.directory_tools import DirectoryTools
|
|
20
22
|
from ..tools.package_tools import PackageTools
|
|
23
|
+
from ..tools.role_tools import RoleTools
|
|
21
24
|
from ..tools.solution_tools import SolutionTools
|
|
22
25
|
from ..tools.view_tools import ViewTools
|
|
23
26
|
from ..tools.workflow_tools import WorkflowTools
|
|
@@ -32,16 +35,20 @@ from .models import (
|
|
|
32
35
|
FieldSelector,
|
|
33
36
|
FieldUpdatePatch,
|
|
34
37
|
FlowPlanRequest,
|
|
38
|
+
FlowAssigneePatch,
|
|
35
39
|
LayoutApplyMode,
|
|
36
40
|
LayoutPlanRequest,
|
|
37
41
|
LayoutSectionPatch,
|
|
38
42
|
LayoutPreset,
|
|
39
43
|
PublicFieldType,
|
|
44
|
+
PublicViewType,
|
|
40
45
|
SchemaPlanRequest,
|
|
41
46
|
ViewUpsertPatch,
|
|
47
|
+
ViewFilterOperator,
|
|
42
48
|
ViewsPlanRequest,
|
|
43
49
|
ViewsPreset,
|
|
44
50
|
FlowPreset,
|
|
51
|
+
FlowNodePermissionsPatch,
|
|
45
52
|
)
|
|
46
53
|
|
|
47
54
|
|
|
@@ -63,6 +70,41 @@ QUESTION_TYPE_TO_FIELD_TYPE: dict[int, str] = {
|
|
|
63
70
|
25: FieldType.relation.value,
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
FIELD_TYPE_TO_QUESTION_TYPE: dict[str, int] = {
|
|
74
|
+
FieldType.text.value: 2,
|
|
75
|
+
FieldType.long_text.value: 3,
|
|
76
|
+
FieldType.date.value: 4,
|
|
77
|
+
FieldType.datetime.value: 4,
|
|
78
|
+
FieldType.member.value: 5,
|
|
79
|
+
FieldType.email.value: 6,
|
|
80
|
+
FieldType.phone.value: 7,
|
|
81
|
+
FieldType.number.value: 8,
|
|
82
|
+
FieldType.amount.value: 8,
|
|
83
|
+
FieldType.boolean.value: 10,
|
|
84
|
+
FieldType.single_select.value: 11,
|
|
85
|
+
FieldType.multi_select.value: 12,
|
|
86
|
+
FieldType.attachment.value: 13,
|
|
87
|
+
FieldType.subtable.value: 18,
|
|
88
|
+
FieldType.address.value: 21,
|
|
89
|
+
FieldType.department.value: 22,
|
|
90
|
+
FieldType.relation.value: 25,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
MATCH_TYPE_ACCURACY = 1
|
|
94
|
+
JUDGE_EQUAL = 0
|
|
95
|
+
JUDGE_UNEQUAL = 1
|
|
96
|
+
JUDGE_GREATER_OR_EQUAL = 5
|
|
97
|
+
JUDGE_LESS_OR_EQUAL = 7
|
|
98
|
+
JUDGE_EQUAL_ANY = 9
|
|
99
|
+
JUDGE_FUZZY_MATCH = 19
|
|
100
|
+
JUDGE_INCLUDE_ANY = 20
|
|
101
|
+
|
|
102
|
+
INCLUDE_ANY_FLOW_FIELD_TYPES = {
|
|
103
|
+
FieldType.multi_select.value,
|
|
104
|
+
FieldType.member.value,
|
|
105
|
+
FieldType.department.value,
|
|
106
|
+
}
|
|
107
|
+
|
|
66
108
|
|
|
67
109
|
@dataclass(slots=True)
|
|
68
110
|
class ResolvedApp:
|
|
@@ -79,12 +121,16 @@ class AiBuilderFacade:
|
|
|
79
121
|
packages: PackageTools,
|
|
80
122
|
views: ViewTools,
|
|
81
123
|
workflows: WorkflowTools,
|
|
124
|
+
roles: RoleTools,
|
|
125
|
+
directory: DirectoryTools,
|
|
82
126
|
solutions: SolutionTools,
|
|
83
127
|
) -> None:
|
|
84
128
|
self.apps = apps
|
|
85
129
|
self.packages = packages
|
|
86
130
|
self.views = views
|
|
87
131
|
self.workflows = workflows
|
|
132
|
+
self.roles = roles
|
|
133
|
+
self.directory = directory
|
|
88
134
|
self.solutions = solutions
|
|
89
135
|
|
|
90
136
|
def package_resolve(self, *, profile: str, package_name: str) -> JSONObject:
|
|
@@ -135,6 +181,75 @@ class AiBuilderFacade:
|
|
|
135
181
|
"match_mode": "exact",
|
|
136
182
|
}
|
|
137
183
|
|
|
184
|
+
def package_create(self, *, profile: str, package_name: str) -> JSONObject:
|
|
185
|
+
requested = str(package_name or "").strip()
|
|
186
|
+
normalized_args = {"package_name": requested}
|
|
187
|
+
if not requested:
|
|
188
|
+
return _failed(
|
|
189
|
+
"PACKAGE_NAME_REQUIRED",
|
|
190
|
+
"package_name is required",
|
|
191
|
+
normalized_args=normalized_args,
|
|
192
|
+
suggested_next_call=None,
|
|
193
|
+
)
|
|
194
|
+
existing = self.package_resolve(profile=profile, package_name=requested)
|
|
195
|
+
if existing.get("status") == "success":
|
|
196
|
+
return {
|
|
197
|
+
"status": "success",
|
|
198
|
+
"error_code": None,
|
|
199
|
+
"recoverable": False,
|
|
200
|
+
"message": "package already exists",
|
|
201
|
+
"normalized_args": normalized_args,
|
|
202
|
+
"missing_fields": [],
|
|
203
|
+
"allowed_values": {},
|
|
204
|
+
"details": {},
|
|
205
|
+
"request_id": None,
|
|
206
|
+
"suggested_next_call": None,
|
|
207
|
+
"noop": True,
|
|
208
|
+
"verification": {"existing_package_reused": True},
|
|
209
|
+
"tag_id": existing.get("tag_id"),
|
|
210
|
+
"tag_name": existing.get("tag_name"),
|
|
211
|
+
}
|
|
212
|
+
if existing.get("error_code") == "AMBIGUOUS_PACKAGE":
|
|
213
|
+
return existing
|
|
214
|
+
try:
|
|
215
|
+
created = self.packages.package_create(profile=profile, payload={"tagName": requested})
|
|
216
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
217
|
+
api_error = _coerce_api_error(error)
|
|
218
|
+
return _failed_from_api_error(
|
|
219
|
+
"PACKAGE_CREATE_FAILED",
|
|
220
|
+
api_error,
|
|
221
|
+
normalized_args=normalized_args,
|
|
222
|
+
details={"package_name": requested},
|
|
223
|
+
suggested_next_call={"tool_name": "package_create", "arguments": {"profile": profile, "package_name": requested}},
|
|
224
|
+
)
|
|
225
|
+
result = created.get("result") if isinstance(created.get("result"), dict) else {}
|
|
226
|
+
tag_id = _coerce_positive_int(result.get("tagId"))
|
|
227
|
+
tag_name = str(result.get("tagName") or requested).strip() or requested
|
|
228
|
+
if tag_id is None:
|
|
229
|
+
resolved = self.package_resolve(profile=profile, package_name=requested)
|
|
230
|
+
if resolved.get("status") == "success":
|
|
231
|
+
tag_id = _coerce_positive_int(resolved.get("tag_id"))
|
|
232
|
+
tag_name = str(resolved.get("tag_name") or tag_name)
|
|
233
|
+
verified = tag_id is not None
|
|
234
|
+
return {
|
|
235
|
+
"status": "success" if verified else "partial_success",
|
|
236
|
+
"error_code": None,
|
|
237
|
+
"recoverable": False,
|
|
238
|
+
"message": "created package" if verified else "created package but could not verify tag id",
|
|
239
|
+
"normalized_args": normalized_args,
|
|
240
|
+
"missing_fields": [],
|
|
241
|
+
"allowed_values": {},
|
|
242
|
+
"details": {},
|
|
243
|
+
"request_id": None,
|
|
244
|
+
"suggested_next_call": None
|
|
245
|
+
if verified
|
|
246
|
+
else {"tool_name": "package_resolve", "arguments": {"profile": profile, "package_name": requested}},
|
|
247
|
+
"noop": False,
|
|
248
|
+
"verification": {"tag_id_verified": verified},
|
|
249
|
+
"tag_id": tag_id,
|
|
250
|
+
"tag_name": tag_name,
|
|
251
|
+
}
|
|
252
|
+
|
|
138
253
|
def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
|
|
139
254
|
listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
|
|
140
255
|
return {
|
|
@@ -157,6 +272,465 @@ class AiBuilderFacade:
|
|
|
157
272
|
"retried": bool(listed.get("retried", False)),
|
|
158
273
|
}
|
|
159
274
|
|
|
275
|
+
def member_search(self, *, profile: str, query: str, page_num: int = 1, page_size: int = 20, contain_disable: bool = False) -> JSONObject:
|
|
276
|
+
requested = str(query or "").strip()
|
|
277
|
+
normalized_args = {
|
|
278
|
+
"query": requested,
|
|
279
|
+
"page_num": page_num,
|
|
280
|
+
"page_size": page_size,
|
|
281
|
+
"contain_disable": contain_disable,
|
|
282
|
+
}
|
|
283
|
+
if not requested:
|
|
284
|
+
return _failed("MEMBER_QUERY_REQUIRED", "query is required", normalized_args=normalized_args, suggested_next_call=None)
|
|
285
|
+
try:
|
|
286
|
+
listed = self.directory.directory_list_internal_users(
|
|
287
|
+
profile=profile,
|
|
288
|
+
keyword=requested,
|
|
289
|
+
dept_id=None,
|
|
290
|
+
role_id=None,
|
|
291
|
+
page_num=page_num,
|
|
292
|
+
page_size=page_size,
|
|
293
|
+
contain_disable=contain_disable,
|
|
294
|
+
)
|
|
295
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
296
|
+
api_error = _coerce_api_error(error)
|
|
297
|
+
return _failed_from_api_error(
|
|
298
|
+
"MEMBER_SEARCH_FAILED",
|
|
299
|
+
api_error,
|
|
300
|
+
normalized_args=normalized_args,
|
|
301
|
+
details={"query": requested},
|
|
302
|
+
suggested_next_call={"tool_name": "member_search", "arguments": {"profile": profile, **normalized_args}},
|
|
303
|
+
)
|
|
304
|
+
items = []
|
|
305
|
+
for item in _extract_directory_items(listed):
|
|
306
|
+
uid = _coerce_positive_int(item.get("uid") or item.get("id"))
|
|
307
|
+
if uid is None:
|
|
308
|
+
continue
|
|
309
|
+
items.append(
|
|
310
|
+
{
|
|
311
|
+
"uid": uid,
|
|
312
|
+
"name": item.get("nickName") or item.get("name") or item.get("value"),
|
|
313
|
+
"email": item.get("email"),
|
|
314
|
+
"dept_name": item.get("deptName") or item.get("departName"),
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
return {
|
|
318
|
+
"status": "success",
|
|
319
|
+
"error_code": None,
|
|
320
|
+
"recoverable": False,
|
|
321
|
+
"message": "resolved members",
|
|
322
|
+
"normalized_args": normalized_args,
|
|
323
|
+
"missing_fields": [],
|
|
324
|
+
"allowed_values": {},
|
|
325
|
+
"details": {},
|
|
326
|
+
"request_id": None,
|
|
327
|
+
"suggested_next_call": None,
|
|
328
|
+
"noop": False,
|
|
329
|
+
"verification": {"count": len(items)},
|
|
330
|
+
"items": items,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
def role_search(self, *, profile: str, keyword: str, page_num: int = 1, page_size: int = 20) -> JSONObject:
|
|
334
|
+
requested = str(keyword or "").strip()
|
|
335
|
+
normalized_args = {"keyword": requested, "page_num": page_num, "page_size": page_size}
|
|
336
|
+
if not requested:
|
|
337
|
+
return _failed("ROLE_QUERY_REQUIRED", "keyword is required", normalized_args=normalized_args, suggested_next_call=None)
|
|
338
|
+
try:
|
|
339
|
+
listed = self.roles.role_search(profile=profile, keyword=requested, page_num=page_num, page_size=page_size)
|
|
340
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
341
|
+
api_error = _coerce_api_error(error)
|
|
342
|
+
return _failed_from_api_error(
|
|
343
|
+
"ROLE_SEARCH_FAILED",
|
|
344
|
+
api_error,
|
|
345
|
+
normalized_args=normalized_args,
|
|
346
|
+
details={"keyword": requested},
|
|
347
|
+
suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, **normalized_args}},
|
|
348
|
+
)
|
|
349
|
+
page = listed.get("page") if isinstance(listed.get("page"), dict) else {}
|
|
350
|
+
raw_items = page.get("list") if isinstance(page.get("list"), list) else []
|
|
351
|
+
items = []
|
|
352
|
+
for item in raw_items:
|
|
353
|
+
if not isinstance(item, dict):
|
|
354
|
+
continue
|
|
355
|
+
role_id = _coerce_positive_int(item.get("roleId") or item.get("id"))
|
|
356
|
+
if role_id is None:
|
|
357
|
+
continue
|
|
358
|
+
items.append(
|
|
359
|
+
{
|
|
360
|
+
"role_id": role_id,
|
|
361
|
+
"role_name": item.get("roleName") or item.get("name"),
|
|
362
|
+
"role_icon": item.get("roleIcon"),
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
return {
|
|
366
|
+
"status": "success",
|
|
367
|
+
"error_code": None,
|
|
368
|
+
"recoverable": False,
|
|
369
|
+
"message": "resolved roles",
|
|
370
|
+
"normalized_args": normalized_args,
|
|
371
|
+
"missing_fields": [],
|
|
372
|
+
"allowed_values": {},
|
|
373
|
+
"details": {},
|
|
374
|
+
"request_id": None,
|
|
375
|
+
"suggested_next_call": None,
|
|
376
|
+
"noop": False,
|
|
377
|
+
"verification": {"count": len(items)},
|
|
378
|
+
"items": items,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
def role_create(
|
|
382
|
+
self,
|
|
383
|
+
*,
|
|
384
|
+
profile: str,
|
|
385
|
+
role_name: str,
|
|
386
|
+
member_uids: list[int],
|
|
387
|
+
member_emails: list[str],
|
|
388
|
+
member_names: list[str],
|
|
389
|
+
role_icon: str = "ex-user-outlined",
|
|
390
|
+
) -> JSONObject:
|
|
391
|
+
normalized_args = {
|
|
392
|
+
"role_name": str(role_name or "").strip(),
|
|
393
|
+
"member_uids": [uid for uid in member_uids if isinstance(uid, int) and uid > 0],
|
|
394
|
+
"member_emails": [str(email or "").strip() for email in member_emails if str(email or "").strip()],
|
|
395
|
+
"member_names": [str(name or "").strip() for name in member_names if str(name or "").strip()],
|
|
396
|
+
"role_icon": role_icon or "ex-user-outlined",
|
|
397
|
+
}
|
|
398
|
+
requested_name = normalized_args["role_name"]
|
|
399
|
+
if not requested_name:
|
|
400
|
+
return _failed("ROLE_NAME_REQUIRED", "role_name is required", normalized_args=normalized_args, suggested_next_call=None)
|
|
401
|
+
existing = self.role_search(profile=profile, keyword=requested_name, page_num=1, page_size=50)
|
|
402
|
+
if existing.get("status") == "success":
|
|
403
|
+
exact = [item for item in existing.get("items", []) if isinstance(item, dict) and item.get("role_name") == requested_name]
|
|
404
|
+
if len(exact) == 1:
|
|
405
|
+
return {
|
|
406
|
+
"status": "success",
|
|
407
|
+
"error_code": None,
|
|
408
|
+
"recoverable": False,
|
|
409
|
+
"message": "role already exists",
|
|
410
|
+
"normalized_args": normalized_args,
|
|
411
|
+
"missing_fields": [],
|
|
412
|
+
"allowed_values": {},
|
|
413
|
+
"details": {},
|
|
414
|
+
"request_id": None,
|
|
415
|
+
"suggested_next_call": None,
|
|
416
|
+
"noop": True,
|
|
417
|
+
"verification": {"existing_role_reused": True},
|
|
418
|
+
"role_id": exact[0]["role_id"],
|
|
419
|
+
"role_name": exact[0]["role_name"],
|
|
420
|
+
"role_icon": exact[0].get("role_icon"),
|
|
421
|
+
}
|
|
422
|
+
if len(exact) > 1:
|
|
423
|
+
return _failed(
|
|
424
|
+
"AMBIGUOUS_ROLE",
|
|
425
|
+
f"multiple roles matched '{requested_name}'",
|
|
426
|
+
normalized_args=normalized_args,
|
|
427
|
+
details={"matches": exact},
|
|
428
|
+
suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": requested_name}},
|
|
429
|
+
)
|
|
430
|
+
resolved_members = self._resolve_member_references(
|
|
431
|
+
profile=profile,
|
|
432
|
+
member_uids=normalized_args["member_uids"],
|
|
433
|
+
member_emails=normalized_args["member_emails"],
|
|
434
|
+
member_names=normalized_args["member_names"],
|
|
435
|
+
)
|
|
436
|
+
if resolved_members["issues"]:
|
|
437
|
+
first_issue = resolved_members["issues"][0]
|
|
438
|
+
return _failed(
|
|
439
|
+
"ROLE_MEMBERS_UNRESOLVED",
|
|
440
|
+
"one or more role members could not be resolved",
|
|
441
|
+
normalized_args=normalized_args,
|
|
442
|
+
details={"issues": resolved_members["issues"]},
|
|
443
|
+
suggested_next_call={"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}},
|
|
444
|
+
)
|
|
445
|
+
try:
|
|
446
|
+
created = self.roles.role_create(
|
|
447
|
+
profile=profile,
|
|
448
|
+
payload={
|
|
449
|
+
"roleName": requested_name,
|
|
450
|
+
"roleIcon": normalized_args["role_icon"],
|
|
451
|
+
"users": resolved_members["member_uids"],
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
455
|
+
api_error = _coerce_api_error(error)
|
|
456
|
+
return _failed_from_api_error(
|
|
457
|
+
"ROLE_CREATE_FAILED",
|
|
458
|
+
api_error,
|
|
459
|
+
normalized_args=normalized_args,
|
|
460
|
+
details={"role_name": requested_name},
|
|
461
|
+
suggested_next_call={"tool_name": "role_create", "arguments": {"profile": profile, **normalized_args}},
|
|
462
|
+
)
|
|
463
|
+
role_result = created.get("result") if isinstance(created.get("result"), dict) else {}
|
|
464
|
+
role_id = _coerce_positive_int(role_result.get("roleId") or role_result.get("id"))
|
|
465
|
+
return {
|
|
466
|
+
"status": "success",
|
|
467
|
+
"error_code": None,
|
|
468
|
+
"recoverable": False,
|
|
469
|
+
"message": "created role",
|
|
470
|
+
"normalized_args": normalized_args,
|
|
471
|
+
"missing_fields": [],
|
|
472
|
+
"allowed_values": {},
|
|
473
|
+
"details": {},
|
|
474
|
+
"request_id": None,
|
|
475
|
+
"suggested_next_call": None,
|
|
476
|
+
"noop": False,
|
|
477
|
+
"verification": {"member_count": len(resolved_members["member_uids"])},
|
|
478
|
+
"role_id": role_id,
|
|
479
|
+
"role_name": requested_name,
|
|
480
|
+
"role_icon": normalized_args["role_icon"],
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
def _resolve_role_references(
|
|
484
|
+
self,
|
|
485
|
+
*,
|
|
486
|
+
profile: str,
|
|
487
|
+
role_ids: list[int],
|
|
488
|
+
role_names: list[str],
|
|
489
|
+
) -> dict[str, Any]:
|
|
490
|
+
issues: list[dict[str, Any]] = []
|
|
491
|
+
resolved: list[dict[str, Any]] = []
|
|
492
|
+
seen_ids: set[int] = set()
|
|
493
|
+
for role_id in role_ids:
|
|
494
|
+
normalized_role_id = _coerce_positive_int(role_id)
|
|
495
|
+
if normalized_role_id is None or normalized_role_id in seen_ids:
|
|
496
|
+
continue
|
|
497
|
+
resolved.append(
|
|
498
|
+
{
|
|
499
|
+
"roleId": normalized_role_id,
|
|
500
|
+
"roleName": str(normalized_role_id),
|
|
501
|
+
"roleIcon": "ex-user-outlined",
|
|
502
|
+
"beingFrontendConfig": True,
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
seen_ids.add(normalized_role_id)
|
|
506
|
+
for role_name in role_names:
|
|
507
|
+
requested = str(role_name or "").strip()
|
|
508
|
+
if not requested:
|
|
509
|
+
continue
|
|
510
|
+
matches_result = self.role_search(profile=profile, keyword=requested, page_num=1, page_size=50)
|
|
511
|
+
items = matches_result.get("items", []) if matches_result.get("status") == "success" else []
|
|
512
|
+
exact = [item for item in items if isinstance(item, dict) and item.get("role_name") == requested]
|
|
513
|
+
if len(exact) != 1:
|
|
514
|
+
issues.append(
|
|
515
|
+
{
|
|
516
|
+
"kind": "role",
|
|
517
|
+
"value": requested,
|
|
518
|
+
"error_code": "AMBIGUOUS_ROLE" if len(exact) > 1 else "ROLE_NOT_FOUND",
|
|
519
|
+
"matches": exact,
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
continue
|
|
523
|
+
role_id = _coerce_positive_int(exact[0].get("role_id"))
|
|
524
|
+
if role_id is None or role_id in seen_ids:
|
|
525
|
+
continue
|
|
526
|
+
resolved.append(
|
|
527
|
+
{
|
|
528
|
+
"roleId": role_id,
|
|
529
|
+
"roleName": exact[0].get("role_name") or requested,
|
|
530
|
+
"roleIcon": exact[0].get("role_icon") or "ex-user-outlined",
|
|
531
|
+
"beingFrontendConfig": True,
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
seen_ids.add(role_id)
|
|
535
|
+
return {"role_entries": resolved, "issues": issues}
|
|
536
|
+
|
|
537
|
+
def _resolve_member_references(
|
|
538
|
+
self,
|
|
539
|
+
*,
|
|
540
|
+
profile: str,
|
|
541
|
+
member_uids: list[int],
|
|
542
|
+
member_emails: list[str],
|
|
543
|
+
member_names: list[str],
|
|
544
|
+
) -> dict[str, Any]:
|
|
545
|
+
issues: list[dict[str, Any]] = []
|
|
546
|
+
resolved: list[dict[str, Any]] = []
|
|
547
|
+
seen_uids: set[int] = set()
|
|
548
|
+
|
|
549
|
+
def add_member(item: dict[str, Any], *, fallback_name: str | None = None) -> None:
|
|
550
|
+
uid = _coerce_positive_int(item.get("uid") or item.get("id"))
|
|
551
|
+
if uid is None or uid in seen_uids:
|
|
552
|
+
return
|
|
553
|
+
resolved.append(
|
|
554
|
+
{
|
|
555
|
+
"uid": uid,
|
|
556
|
+
"name": item.get("nickName") or item.get("name") or fallback_name or str(uid),
|
|
557
|
+
"email": item.get("email"),
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
seen_uids.add(uid)
|
|
561
|
+
|
|
562
|
+
for uid in member_uids:
|
|
563
|
+
normalized_uid = _coerce_positive_int(uid)
|
|
564
|
+
if normalized_uid is not None and normalized_uid not in seen_uids:
|
|
565
|
+
add_member({"uid": normalized_uid}, fallback_name=str(normalized_uid))
|
|
566
|
+
|
|
567
|
+
for email in member_emails:
|
|
568
|
+
requested = str(email or "").strip()
|
|
569
|
+
if not requested:
|
|
570
|
+
continue
|
|
571
|
+
matches = self.member_search(profile=profile, query=requested, page_num=1, page_size=50, contain_disable=False)
|
|
572
|
+
items = matches.get("items", []) if matches.get("status") == "success" else []
|
|
573
|
+
exact = [item for item in items if isinstance(item, dict) and str(item.get("email") or "").strip().lower() == requested.lower()]
|
|
574
|
+
if len(exact) != 1:
|
|
575
|
+
issues.append(
|
|
576
|
+
{
|
|
577
|
+
"kind": "member_email",
|
|
578
|
+
"value": requested,
|
|
579
|
+
"error_code": "AMBIGUOUS_MEMBER" if len(exact) > 1 else "MEMBER_NOT_FOUND",
|
|
580
|
+
"matches": exact,
|
|
581
|
+
}
|
|
582
|
+
)
|
|
583
|
+
continue
|
|
584
|
+
add_member(exact[0])
|
|
585
|
+
|
|
586
|
+
for name in member_names:
|
|
587
|
+
requested = str(name or "").strip()
|
|
588
|
+
if not requested:
|
|
589
|
+
continue
|
|
590
|
+
matches = self.member_search(profile=profile, query=requested, page_num=1, page_size=50, contain_disable=False)
|
|
591
|
+
items = matches.get("items", []) if matches.get("status") == "success" else []
|
|
592
|
+
exact = [item for item in items if isinstance(item, dict) and str(item.get("name") or "").strip() == requested]
|
|
593
|
+
if len(exact) != 1:
|
|
594
|
+
issues.append(
|
|
595
|
+
{
|
|
596
|
+
"kind": "member_name",
|
|
597
|
+
"value": requested,
|
|
598
|
+
"error_code": "AMBIGUOUS_MEMBER" if len(exact) > 1 else "MEMBER_NOT_FOUND",
|
|
599
|
+
"matches": exact,
|
|
600
|
+
}
|
|
601
|
+
)
|
|
602
|
+
continue
|
|
603
|
+
add_member(exact[0])
|
|
604
|
+
|
|
605
|
+
return {"member_uids": [item["uid"] for item in resolved], "member_entries": resolved, "issues": issues}
|
|
606
|
+
|
|
607
|
+
def _normalize_flow_nodes(
|
|
608
|
+
self,
|
|
609
|
+
*,
|
|
610
|
+
profile: str,
|
|
611
|
+
current_fields: list[dict[str, Any]],
|
|
612
|
+
nodes: list[dict[str, Any]],
|
|
613
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
614
|
+
field_name_to_field = {
|
|
615
|
+
str(field.get("name") or ""): field
|
|
616
|
+
for field in current_fields
|
|
617
|
+
if str(field.get("name") or "")
|
|
618
|
+
}
|
|
619
|
+
field_name_to_que_id = {
|
|
620
|
+
str(field.get("name") or ""): int(field.get("que_id"))
|
|
621
|
+
for field in current_fields
|
|
622
|
+
if str(field.get("name") or "") and isinstance(field.get("que_id"), int)
|
|
623
|
+
}
|
|
624
|
+
normalized_nodes: list[dict[str, Any]] = []
|
|
625
|
+
issues: list[dict[str, Any]] = []
|
|
626
|
+
for node in nodes:
|
|
627
|
+
if not isinstance(node, dict):
|
|
628
|
+
continue
|
|
629
|
+
normalized_node = deepcopy(node)
|
|
630
|
+
assignees = FlowAssigneePatch.model_validate(node.get("assignees") or {})
|
|
631
|
+
permissions = FlowNodePermissionsPatch.model_validate(node.get("permissions") or {})
|
|
632
|
+
role_resolution = self._resolve_role_references(
|
|
633
|
+
profile=profile,
|
|
634
|
+
role_ids=assignees.role_ids,
|
|
635
|
+
role_names=assignees.role_names,
|
|
636
|
+
)
|
|
637
|
+
member_resolution = self._resolve_member_references(
|
|
638
|
+
profile=profile,
|
|
639
|
+
member_uids=assignees.member_uids,
|
|
640
|
+
member_emails=assignees.member_emails,
|
|
641
|
+
member_names=assignees.member_names,
|
|
642
|
+
)
|
|
643
|
+
issues.extend({**issue, "node_id": node.get("id")} for issue in [*role_resolution["issues"], *member_resolution["issues"]])
|
|
644
|
+
editable_que_ids: list[int] = []
|
|
645
|
+
missing_editable_fields: list[str] = []
|
|
646
|
+
for field_name in permissions.editable_fields:
|
|
647
|
+
if field_name not in field_name_to_que_id:
|
|
648
|
+
missing_editable_fields.append(field_name)
|
|
649
|
+
else:
|
|
650
|
+
editable_que_ids.append(field_name_to_que_id[field_name])
|
|
651
|
+
if missing_editable_fields:
|
|
652
|
+
issues.append(
|
|
653
|
+
{
|
|
654
|
+
"node_id": node.get("id"),
|
|
655
|
+
"kind": "editable_fields",
|
|
656
|
+
"error_code": "UNKNOWN_FLOW_FIELD",
|
|
657
|
+
"missing_fields": missing_editable_fields,
|
|
658
|
+
}
|
|
659
|
+
)
|
|
660
|
+
condition_matrix, condition_issues = _build_flow_condition_matrix(
|
|
661
|
+
current_fields_by_name=field_name_to_field,
|
|
662
|
+
node=normalized_node,
|
|
663
|
+
)
|
|
664
|
+
issues.extend({**issue, "node_id": node.get("id")} for issue in condition_issues)
|
|
665
|
+
config_payload = deepcopy(normalized_node.get("config") or {}) if isinstance(normalized_node.get("config"), dict) else {}
|
|
666
|
+
if condition_matrix:
|
|
667
|
+
config_payload["conditionFormatMatrix"] = condition_matrix
|
|
668
|
+
normalized_node["assignees"] = {
|
|
669
|
+
"member_uids": member_resolution["member_uids"],
|
|
670
|
+
"role_entries": role_resolution["role_entries"],
|
|
671
|
+
"include_sub_departs": assignees.include_sub_departs,
|
|
672
|
+
}
|
|
673
|
+
normalized_node["permissions"] = {
|
|
674
|
+
"editable_fields": permissions.editable_fields,
|
|
675
|
+
"editable_que_ids": editable_que_ids,
|
|
676
|
+
}
|
|
677
|
+
if config_payload:
|
|
678
|
+
normalized_node["config"] = config_payload
|
|
679
|
+
normalized_nodes.append(normalized_node)
|
|
680
|
+
return normalized_nodes, issues
|
|
681
|
+
|
|
682
|
+
def _canonicalize_flow_nodes_for_public_output(self, nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
683
|
+
public_nodes: list[dict[str, Any]] = []
|
|
684
|
+
for node in nodes:
|
|
685
|
+
if not isinstance(node, dict):
|
|
686
|
+
continue
|
|
687
|
+
payload = deepcopy(node)
|
|
688
|
+
assignees = payload.get("assignees") if isinstance(payload.get("assignees"), dict) else {}
|
|
689
|
+
permissions = payload.get("permissions") if isinstance(payload.get("permissions"), dict) else {}
|
|
690
|
+
public_assignees: dict[str, Any] = {}
|
|
691
|
+
role_ids = [
|
|
692
|
+
role_id
|
|
693
|
+
for role_id in (
|
|
694
|
+
_coerce_positive_int(entry.get("roleId"))
|
|
695
|
+
for entry in (assignees.get("role_entries") or [])
|
|
696
|
+
if isinstance(entry, dict)
|
|
697
|
+
)
|
|
698
|
+
if role_id is not None
|
|
699
|
+
]
|
|
700
|
+
member_uids = [
|
|
701
|
+
member_uid
|
|
702
|
+
for member_uid in (_coerce_positive_int(value) for value in (assignees.get("member_uids") or []))
|
|
703
|
+
if member_uid is not None
|
|
704
|
+
]
|
|
705
|
+
if role_ids:
|
|
706
|
+
public_assignees["role_ids"] = role_ids
|
|
707
|
+
if member_uids:
|
|
708
|
+
public_assignees["member_uids"] = member_uids
|
|
709
|
+
if bool(assignees.get("include_sub_departs")):
|
|
710
|
+
public_assignees["include_sub_departs"] = True
|
|
711
|
+
public_permissions: dict[str, Any] = {}
|
|
712
|
+
editable_fields = [str(name) for name in (permissions.get("editable_fields") or []) if str(name or "").strip()]
|
|
713
|
+
if editable_fields:
|
|
714
|
+
public_permissions["editable_fields"] = editable_fields
|
|
715
|
+
config_payload = payload.get("config") if isinstance(payload.get("config"), dict) else {}
|
|
716
|
+
if isinstance(config_payload, dict):
|
|
717
|
+
config_payload = deepcopy(config_payload)
|
|
718
|
+
config_payload.pop("conditionFormatMatrix", None)
|
|
719
|
+
if public_assignees:
|
|
720
|
+
payload["assignees"] = public_assignees
|
|
721
|
+
else:
|
|
722
|
+
payload.pop("assignees", None)
|
|
723
|
+
if public_permissions:
|
|
724
|
+
payload["permissions"] = public_permissions
|
|
725
|
+
else:
|
|
726
|
+
payload.pop("permissions", None)
|
|
727
|
+
if config_payload:
|
|
728
|
+
payload["config"] = config_payload
|
|
729
|
+
else:
|
|
730
|
+
payload.pop("config", None)
|
|
731
|
+
public_nodes.append(payload)
|
|
732
|
+
return public_nodes
|
|
733
|
+
|
|
160
734
|
def package_attach_app(
|
|
161
735
|
self,
|
|
162
736
|
*,
|
|
@@ -210,6 +784,115 @@ class AiBuilderFacade:
|
|
|
210
784
|
"attached": attached,
|
|
211
785
|
}
|
|
212
786
|
|
|
787
|
+
def app_release_edit_lock_if_mine(
|
|
788
|
+
self,
|
|
789
|
+
*,
|
|
790
|
+
profile: str,
|
|
791
|
+
app_key: str,
|
|
792
|
+
lock_owner_email: str = "",
|
|
793
|
+
lock_owner_name: str = "",
|
|
794
|
+
) -> JSONObject:
|
|
795
|
+
normalized_args = {
|
|
796
|
+
"app_key": app_key,
|
|
797
|
+
"lock_owner_email": lock_owner_email,
|
|
798
|
+
"lock_owner_name": lock_owner_name,
|
|
799
|
+
}
|
|
800
|
+
session_profile = self.apps.sessions.get_profile(profile)
|
|
801
|
+
if session_profile is None:
|
|
802
|
+
return _failed(
|
|
803
|
+
"AUTH_REQUIRED",
|
|
804
|
+
"auth profile is required before releasing an app edit lock",
|
|
805
|
+
normalized_args=normalized_args,
|
|
806
|
+
recoverable=False,
|
|
807
|
+
suggested_next_call={"tool_name": "auth_whoami", "arguments": {"profile": profile}},
|
|
808
|
+
)
|
|
809
|
+
identity = self._resolve_current_user_identity(profile=profile)
|
|
810
|
+
current_email = str(identity.get("email") or "").strip().lower()
|
|
811
|
+
current_name = str(identity.get("nick_name") or "").strip()
|
|
812
|
+
requested_owner_email = str(lock_owner_email or "").strip().lower()
|
|
813
|
+
requested_owner_name = str(lock_owner_name or "").strip()
|
|
814
|
+
if not requested_owner_email and not requested_owner_name:
|
|
815
|
+
return _failed(
|
|
816
|
+
"EDIT_LOCK_OWNER_UNKNOWN",
|
|
817
|
+
"lock owner could not be verified; refuse to release edit lock blindly",
|
|
818
|
+
normalized_args=normalized_args,
|
|
819
|
+
recoverable=False,
|
|
820
|
+
details={
|
|
821
|
+
"current_user_email": identity.get("email"),
|
|
822
|
+
"current_user_name": identity.get("nick_name"),
|
|
823
|
+
},
|
|
824
|
+
suggested_next_call=None,
|
|
825
|
+
)
|
|
826
|
+
owner_matches = True
|
|
827
|
+
if requested_owner_email:
|
|
828
|
+
owner_matches = bool(current_email) and current_email == requested_owner_email
|
|
829
|
+
elif requested_owner_name:
|
|
830
|
+
owner_matches = bool(current_name) and current_name == requested_owner_name
|
|
831
|
+
if not owner_matches:
|
|
832
|
+
return _failed(
|
|
833
|
+
"EDIT_LOCK_HELD_BY_OTHER_USER",
|
|
834
|
+
"edit lock is owned by another user; refusing to release it",
|
|
835
|
+
normalized_args=normalized_args,
|
|
836
|
+
recoverable=False,
|
|
837
|
+
details={
|
|
838
|
+
"lock_owner_email": requested_owner_email or None,
|
|
839
|
+
"lock_owner_name": requested_owner_name or None,
|
|
840
|
+
"current_user_email": identity.get("email"),
|
|
841
|
+
"current_user_name": identity.get("nick_name"),
|
|
842
|
+
},
|
|
843
|
+
suggested_next_call=None,
|
|
844
|
+
)
|
|
845
|
+
try:
|
|
846
|
+
version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
|
|
847
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
848
|
+
api_error = _coerce_api_error(error)
|
|
849
|
+
return _failed_from_api_error(
|
|
850
|
+
"EDIT_LOCK_RELEASE_FAILED",
|
|
851
|
+
api_error,
|
|
852
|
+
normalized_args=normalized_args,
|
|
853
|
+
details={"app_key": app_key},
|
|
854
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
855
|
+
)
|
|
856
|
+
edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
|
|
857
|
+
try:
|
|
858
|
+
self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
859
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
860
|
+
api_error = _coerce_api_error(error)
|
|
861
|
+
return _failed_from_api_error(
|
|
862
|
+
"EDIT_LOCK_RELEASE_FAILED",
|
|
863
|
+
api_error,
|
|
864
|
+
normalized_args=normalized_args,
|
|
865
|
+
details={
|
|
866
|
+
"app_key": app_key,
|
|
867
|
+
"edit_version_no": edit_version_no,
|
|
868
|
+
"lock_owner_email": requested_owner_email or None,
|
|
869
|
+
"lock_owner_name": requested_owner_name or None,
|
|
870
|
+
},
|
|
871
|
+
suggested_next_call={"tool_name": "app_release_edit_lock_if_mine", "arguments": {"profile": profile, **normalized_args}},
|
|
872
|
+
)
|
|
873
|
+
return {
|
|
874
|
+
"status": "success",
|
|
875
|
+
"error_code": None,
|
|
876
|
+
"recoverable": False,
|
|
877
|
+
"message": "released app edit lock owned by current user",
|
|
878
|
+
"normalized_args": normalized_args,
|
|
879
|
+
"missing_fields": [],
|
|
880
|
+
"allowed_values": {},
|
|
881
|
+
"details": {
|
|
882
|
+
"lock_owner_email": requested_owner_email or None,
|
|
883
|
+
"lock_owner_name": requested_owner_name or None,
|
|
884
|
+
"current_user_email": identity.get("email"),
|
|
885
|
+
"current_user_name": identity.get("nick_name"),
|
|
886
|
+
"edit_version_no": edit_version_no,
|
|
887
|
+
},
|
|
888
|
+
"request_id": None,
|
|
889
|
+
"suggested_next_call": {"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
890
|
+
"noop": False,
|
|
891
|
+
"verification": {"released": True},
|
|
892
|
+
"app_key": app_key,
|
|
893
|
+
"released": True,
|
|
894
|
+
}
|
|
895
|
+
|
|
213
896
|
def app_resolve(
|
|
214
897
|
self,
|
|
215
898
|
*,
|
|
@@ -221,8 +904,14 @@ class AiBuilderFacade:
|
|
|
221
904
|
if app_key:
|
|
222
905
|
try:
|
|
223
906
|
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
|
|
224
|
-
except RuntimeError as exc:
|
|
225
|
-
|
|
907
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
908
|
+
api_error = _coerce_api_error(exc)
|
|
909
|
+
return _failed_from_api_error(
|
|
910
|
+
"APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
|
|
911
|
+
api_error,
|
|
912
|
+
details={"app_key": app_key},
|
|
913
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
914
|
+
)
|
|
226
915
|
result = base.get("result") if isinstance(base.get("result"), dict) else {}
|
|
227
916
|
return {
|
|
228
917
|
"status": "success",
|
|
@@ -299,8 +988,30 @@ class AiBuilderFacade:
|
|
|
299
988
|
}
|
|
300
989
|
|
|
301
990
|
def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
302
|
-
|
|
991
|
+
try:
|
|
992
|
+
state = self._load_base_schema_state(profile=profile, app_key=app_key)
|
|
993
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
994
|
+
api_error = _coerce_api_error(error)
|
|
995
|
+
return _failed_from_api_error(
|
|
996
|
+
"APP_READ_FAILED",
|
|
997
|
+
api_error,
|
|
998
|
+
normalized_args={"app_key": app_key},
|
|
999
|
+
details={"app_key": app_key},
|
|
1000
|
+
suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1001
|
+
)
|
|
1002
|
+
views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
1003
|
+
workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
303
1004
|
parsed = state["parsed"]
|
|
1005
|
+
verification_hints = _build_verification_hints(
|
|
1006
|
+
tag_ids=_coerce_int_list(state["base"].get("tagIds")),
|
|
1007
|
+
fields=parsed["fields"],
|
|
1008
|
+
layout=parsed["layout"],
|
|
1009
|
+
views=_summarize_views(views),
|
|
1010
|
+
)
|
|
1011
|
+
if views_unavailable:
|
|
1012
|
+
verification_hints.append("views_read_unavailable")
|
|
1013
|
+
if workflow_unavailable:
|
|
1014
|
+
verification_hints.append("workflow_read_unavailable")
|
|
304
1015
|
response = AppReadSummaryResponse(
|
|
305
1016
|
app_key=app_key,
|
|
306
1017
|
title=state["base"].get("formTitle"),
|
|
@@ -308,14 +1019,9 @@ class AiBuilderFacade:
|
|
|
308
1019
|
publish_status=state["base"].get("appPublishStatus"),
|
|
309
1020
|
field_count=len(parsed["fields"]),
|
|
310
1021
|
layout_section_count=len(parsed["layout"].get("sections", [])),
|
|
311
|
-
view_count=len(_summarize_views(
|
|
312
|
-
workflow_enabled=bool(
|
|
313
|
-
verification_hints=
|
|
314
|
-
tag_ids=_coerce_int_list(state["base"].get("tagIds")),
|
|
315
|
-
fields=parsed["fields"],
|
|
316
|
-
layout=parsed["layout"],
|
|
317
|
-
views=_summarize_views(state["views"]),
|
|
318
|
-
),
|
|
1022
|
+
view_count=len(_summarize_views(views)),
|
|
1023
|
+
workflow_enabled=bool(workflow),
|
|
1024
|
+
verification_hints=verification_hints,
|
|
319
1025
|
)
|
|
320
1026
|
return {
|
|
321
1027
|
"status": "success",
|
|
@@ -334,7 +1040,17 @@ class AiBuilderFacade:
|
|
|
334
1040
|
}
|
|
335
1041
|
|
|
336
1042
|
def app_read_fields(self, *, profile: str, app_key: str) -> JSONObject:
|
|
337
|
-
|
|
1043
|
+
try:
|
|
1044
|
+
state = self._load_base_schema_state(profile=profile, app_key=app_key)
|
|
1045
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1046
|
+
api_error = _coerce_api_error(error)
|
|
1047
|
+
return _failed_from_api_error(
|
|
1048
|
+
"FIELDS_READ_FAILED",
|
|
1049
|
+
api_error,
|
|
1050
|
+
normalized_args={"app_key": app_key},
|
|
1051
|
+
details={"app_key": app_key},
|
|
1052
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1053
|
+
)
|
|
338
1054
|
parsed = state["parsed"]
|
|
339
1055
|
response = AppFieldsReadResponse(
|
|
340
1056
|
app_key=app_key,
|
|
@@ -368,7 +1084,17 @@ class AiBuilderFacade:
|
|
|
368
1084
|
}
|
|
369
1085
|
|
|
370
1086
|
def app_read_layout_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
371
|
-
|
|
1087
|
+
try:
|
|
1088
|
+
state = self._load_base_schema_state(profile=profile, app_key=app_key)
|
|
1089
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1090
|
+
api_error = _coerce_api_error(error)
|
|
1091
|
+
return _failed_from_api_error(
|
|
1092
|
+
"LAYOUT_READ_FAILED",
|
|
1093
|
+
api_error,
|
|
1094
|
+
normalized_args={"app_key": app_key},
|
|
1095
|
+
details={"app_key": app_key},
|
|
1096
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1097
|
+
)
|
|
372
1098
|
parsed = state["parsed"]
|
|
373
1099
|
layout = parsed["layout"]
|
|
374
1100
|
response = AppLayoutReadResponse(
|
|
@@ -394,10 +1120,20 @@ class AiBuilderFacade:
|
|
|
394
1120
|
}
|
|
395
1121
|
|
|
396
1122
|
def app_read_views_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
397
|
-
|
|
1123
|
+
try:
|
|
1124
|
+
views, _ = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
|
|
1125
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1126
|
+
api_error = _coerce_api_error(error)
|
|
1127
|
+
return _failed_from_api_error(
|
|
1128
|
+
"VIEWS_READ_FAILED",
|
|
1129
|
+
api_error,
|
|
1130
|
+
normalized_args={"app_key": app_key},
|
|
1131
|
+
details={"app_key": app_key},
|
|
1132
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1133
|
+
)
|
|
398
1134
|
response = AppViewsReadResponse(
|
|
399
1135
|
app_key=app_key,
|
|
400
|
-
views=_summarize_views(
|
|
1136
|
+
views=_summarize_views(views),
|
|
401
1137
|
)
|
|
402
1138
|
return {
|
|
403
1139
|
"status": "success",
|
|
@@ -416,11 +1152,21 @@ class AiBuilderFacade:
|
|
|
416
1152
|
}
|
|
417
1153
|
|
|
418
1154
|
def app_read_flow_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
419
|
-
|
|
1155
|
+
try:
|
|
1156
|
+
workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
1157
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1158
|
+
api_error = _coerce_api_error(error)
|
|
1159
|
+
return _failed_from_api_error(
|
|
1160
|
+
"FLOW_READ_FAILED",
|
|
1161
|
+
api_error,
|
|
1162
|
+
normalized_args={"app_key": app_key},
|
|
1163
|
+
details={"app_key": app_key},
|
|
1164
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1165
|
+
)
|
|
420
1166
|
response = AppFlowReadResponse(
|
|
421
1167
|
app_key=app_key,
|
|
422
|
-
enabled=bool(
|
|
423
|
-
nodes=_summarize_workflow_nodes(
|
|
1168
|
+
enabled=bool(workflow),
|
|
1169
|
+
nodes=_summarize_workflow_nodes(workflow),
|
|
424
1170
|
transitions=[],
|
|
425
1171
|
)
|
|
426
1172
|
return {
|
|
@@ -435,7 +1181,7 @@ class AiBuilderFacade:
|
|
|
435
1181
|
"request_id": None,
|
|
436
1182
|
"suggested_next_call": None,
|
|
437
1183
|
"noop": False,
|
|
438
|
-
"verification": {"app_exists": True},
|
|
1184
|
+
"verification": {"app_exists": True, "workflow_read_unavailable": workflow_unavailable},
|
|
439
1185
|
**response.model_dump(mode="json"),
|
|
440
1186
|
}
|
|
441
1187
|
|
|
@@ -453,7 +1199,11 @@ class AiBuilderFacade:
|
|
|
453
1199
|
return target
|
|
454
1200
|
current_fields: list[dict[str, Any]] = []
|
|
455
1201
|
if not bool(target.get("would_create")):
|
|
456
|
-
|
|
1202
|
+
fields_result = self.app_read_fields(profile=profile, app_key=str(target["app_key"]))
|
|
1203
|
+
if fields_result.get("status") == "failed":
|
|
1204
|
+
fields_result.setdefault("normalized_args", normalized_args)
|
|
1205
|
+
return fields_result
|
|
1206
|
+
current_fields = fields_result.get("fields", [])
|
|
457
1207
|
current_by_name = {str(field.get("name") or ""): field for field in current_fields}
|
|
458
1208
|
blocking_issues: list[dict[str, Any]] = []
|
|
459
1209
|
preview_added: list[str] = []
|
|
@@ -511,11 +1261,31 @@ class AiBuilderFacade:
|
|
|
511
1261
|
|
|
512
1262
|
def app_layout_plan(self, *, profile: str, request: LayoutPlanRequest) -> JSONObject:
|
|
513
1263
|
read_fields = self.app_read_fields(profile=profile, app_key=request.app_key)
|
|
514
|
-
|
|
1264
|
+
if read_fields.get("status") == "failed":
|
|
1265
|
+
return read_fields
|
|
1266
|
+
current_fields = [field for field in read_fields.get("fields", []) if isinstance(field, dict)]
|
|
1267
|
+
current_names = [str(field.get("name") or "") for field in current_fields if field.get("name")]
|
|
515
1268
|
current_layout = self.app_read_layout_summary(profile=profile, app_key=request.app_key)
|
|
1269
|
+
if current_layout.get("status") == "failed":
|
|
1270
|
+
return current_layout
|
|
516
1271
|
requested_sections = [section.model_dump(mode="json") for section in request.sections]
|
|
517
1272
|
if request.preset is not None:
|
|
518
1273
|
requested_sections = _build_layout_preset_sections(preset=request.preset, field_names=current_names)
|
|
1274
|
+
else:
|
|
1275
|
+
requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
|
|
1276
|
+
if missing_selectors:
|
|
1277
|
+
return _failed(
|
|
1278
|
+
"UNKNOWN_LAYOUT_FIELD",
|
|
1279
|
+
"layout references unknown field selectors",
|
|
1280
|
+
normalized_args={
|
|
1281
|
+
"app_key": request.app_key,
|
|
1282
|
+
"mode": request.mode.value,
|
|
1283
|
+
"sections": requested_sections,
|
|
1284
|
+
},
|
|
1285
|
+
details={"unknown_selectors": missing_selectors},
|
|
1286
|
+
missing_fields=[str(item) for item in missing_selectors],
|
|
1287
|
+
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": request.app_key}},
|
|
1288
|
+
)
|
|
519
1289
|
merged = _merge_layout(
|
|
520
1290
|
current_layout={
|
|
521
1291
|
"root_rows": [],
|
|
@@ -574,10 +1344,66 @@ class AiBuilderFacade:
|
|
|
574
1344
|
nodes = [node.model_dump(mode="json") for node in request.nodes]
|
|
575
1345
|
transitions = [transition.model_dump(mode="json", by_alias=True) for transition in request.transitions]
|
|
576
1346
|
if request.preset is not None:
|
|
577
|
-
|
|
578
|
-
|
|
1347
|
+
preset_nodes, preset_transitions = _build_flow_preset(request.preset)
|
|
1348
|
+
nodes, transitions = _merge_flow_graph(
|
|
1349
|
+
base_nodes=preset_nodes,
|
|
1350
|
+
base_transitions=preset_transitions,
|
|
1351
|
+
override_nodes=nodes,
|
|
1352
|
+
override_transitions=transitions,
|
|
1353
|
+
)
|
|
1354
|
+
fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
|
|
1355
|
+
if fields_result.get("status") == "failed":
|
|
1356
|
+
return fields_result
|
|
1357
|
+
current_fields = fields_result.get("fields", [])
|
|
1358
|
+
normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
|
|
1359
|
+
public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
|
|
1360
|
+
if resolution_issues:
|
|
1361
|
+
first_issue = resolution_issues[0]
|
|
1362
|
+
suggested_call = None
|
|
1363
|
+
if first_issue.get("kind", "").startswith("role"):
|
|
1364
|
+
suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
|
|
1365
|
+
elif first_issue.get("kind", "").startswith("member"):
|
|
1366
|
+
suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
|
|
1367
|
+
elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
|
|
1368
|
+
suggested_call = {"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": request.app_key}}
|
|
1369
|
+
return _failed(
|
|
1370
|
+
first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
|
|
1371
|
+
"workflow contains unresolved assignees or field permissions",
|
|
1372
|
+
normalized_args={
|
|
1373
|
+
"app_key": request.app_key,
|
|
1374
|
+
"mode": str(request.mode or "replace"),
|
|
1375
|
+
"preset": request.preset.value if request.preset else None,
|
|
1376
|
+
"nodes": public_nodes,
|
|
1377
|
+
"transitions": transitions,
|
|
1378
|
+
},
|
|
1379
|
+
details={"issues": resolution_issues},
|
|
1380
|
+
suggested_next_call=suggested_call,
|
|
1381
|
+
)
|
|
579
1382
|
status_field_present = _infer_status_field_id(current_fields) is not None
|
|
580
|
-
node_types = {str(node.get("type") or "") for node in
|
|
1383
|
+
node_types = {str(node.get("type") or "") for node in normalized_nodes}
|
|
1384
|
+
assignee_required_nodes = [
|
|
1385
|
+
node.get("id")
|
|
1386
|
+
for node in normalized_nodes
|
|
1387
|
+
if str(node.get("type") or "") in {"approve", "fill", "copy"}
|
|
1388
|
+
and not (
|
|
1389
|
+
(node.get("assignees") or {}).get("role_entries")
|
|
1390
|
+
or (node.get("assignees") or {}).get("member_uids")
|
|
1391
|
+
)
|
|
1392
|
+
]
|
|
1393
|
+
if assignee_required_nodes:
|
|
1394
|
+
return _failed(
|
|
1395
|
+
"FLOW_ASSIGNEE_REQUIRED",
|
|
1396
|
+
"workflow approval/fill/copy nodes must declare at least one role or member assignee",
|
|
1397
|
+
normalized_args={
|
|
1398
|
+
"app_key": request.app_key,
|
|
1399
|
+
"mode": str(request.mode or "replace"),
|
|
1400
|
+
"preset": request.preset.value if request.preset else None,
|
|
1401
|
+
"nodes": public_nodes,
|
|
1402
|
+
"transitions": transitions,
|
|
1403
|
+
},
|
|
1404
|
+
details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
|
|
1405
|
+
suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
|
|
1406
|
+
)
|
|
581
1407
|
if ("approve" in node_types or request.preset in {FlowPreset.basic_approval, FlowPreset.basic_fill_then_approve}) and not status_field_present:
|
|
582
1408
|
return _failed(
|
|
583
1409
|
"FLOW_DEPENDENCY_MISSING",
|
|
@@ -585,7 +1411,7 @@ class AiBuilderFacade:
|
|
|
585
1411
|
normalized_args={
|
|
586
1412
|
"app_key": request.app_key,
|
|
587
1413
|
"mode": str(request.mode or "replace"),
|
|
588
|
-
"nodes":
|
|
1414
|
+
"nodes": public_nodes,
|
|
589
1415
|
"transitions": transitions,
|
|
590
1416
|
},
|
|
591
1417
|
details={"missing_dependencies": ["status field"]},
|
|
@@ -602,12 +1428,12 @@ class AiBuilderFacade:
|
|
|
602
1428
|
},
|
|
603
1429
|
},
|
|
604
1430
|
)
|
|
605
|
-
workflow = _build_public_workflow_spec(nodes=
|
|
1431
|
+
workflow = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
|
|
606
1432
|
if workflow.get("status") == "failed":
|
|
607
1433
|
workflow["normalized_args"] = {
|
|
608
1434
|
"app_key": request.app_key,
|
|
609
1435
|
"mode": str(request.mode or "replace"),
|
|
610
|
-
"nodes":
|
|
1436
|
+
"nodes": public_nodes,
|
|
611
1437
|
"transitions": transitions,
|
|
612
1438
|
}
|
|
613
1439
|
workflow["suggested_next_call"] = {
|
|
@@ -616,7 +1442,7 @@ class AiBuilderFacade:
|
|
|
616
1442
|
"profile": profile,
|
|
617
1443
|
"app_key": request.app_key,
|
|
618
1444
|
"mode": "replace",
|
|
619
|
-
"nodes":
|
|
1445
|
+
"nodes": public_nodes,
|
|
620
1446
|
"transitions": transitions,
|
|
621
1447
|
},
|
|
622
1448
|
}
|
|
@@ -624,7 +1450,7 @@ class AiBuilderFacade:
|
|
|
624
1450
|
normalized_args = {
|
|
625
1451
|
"app_key": request.app_key,
|
|
626
1452
|
"mode": str(request.mode or "replace"),
|
|
627
|
-
"nodes":
|
|
1453
|
+
"nodes": public_nodes,
|
|
628
1454
|
"transitions": transitions,
|
|
629
1455
|
}
|
|
630
1456
|
return {
|
|
@@ -645,8 +1471,16 @@ class AiBuilderFacade:
|
|
|
645
1471
|
}
|
|
646
1472
|
|
|
647
1473
|
def app_views_plan(self, *, profile: str, request: ViewsPlanRequest) -> JSONObject:
|
|
648
|
-
|
|
1474
|
+
fields_result = self.app_read_fields(profile=profile, app_key=request.app_key)
|
|
1475
|
+
if fields_result.get("status") == "failed":
|
|
1476
|
+
return fields_result
|
|
1477
|
+
current_fields = fields_result.get("fields", [])
|
|
649
1478
|
field_names = {str(field.get("name") or "") for field in current_fields}
|
|
1479
|
+
current_fields_by_name = {
|
|
1480
|
+
str(field.get("name") or ""): field
|
|
1481
|
+
for field in current_fields
|
|
1482
|
+
if isinstance(field, dict) and str(field.get("name") or "")
|
|
1483
|
+
}
|
|
650
1484
|
upsert_views = [view.model_dump(mode="json") for view in request.upsert_views]
|
|
651
1485
|
if request.preset is not None:
|
|
652
1486
|
upsert_views = _build_views_preset(request.preset, list(field_names))
|
|
@@ -659,6 +1493,31 @@ class AiBuilderFacade:
|
|
|
659
1493
|
group_by = patch.get("group_by")
|
|
660
1494
|
if group_by and group_by not in field_names:
|
|
661
1495
|
blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": [group_by]})
|
|
1496
|
+
start_field = str(patch.get("start_field") or "").strip()
|
|
1497
|
+
end_field = str(patch.get("end_field") or "").strip()
|
|
1498
|
+
title_field = str(patch.get("title_field") or "").strip()
|
|
1499
|
+
if patch.get("type") == "gantt":
|
|
1500
|
+
missing_required = []
|
|
1501
|
+
if not start_field:
|
|
1502
|
+
missing_required.append("start_field")
|
|
1503
|
+
if not end_field:
|
|
1504
|
+
missing_required.append("end_field")
|
|
1505
|
+
if missing_required:
|
|
1506
|
+
blocking_issues.append({"error_code": "INVALID_GANTT_CONFIG", "view_name": patch.get("name"), "missing_fields": missing_required})
|
|
1507
|
+
missing_gantt_fields = [name for name in (start_field, end_field, title_field) if name and name not in field_names]
|
|
1508
|
+
if missing_gantt_fields:
|
|
1509
|
+
blocking_issues.append({"error_code": "UNKNOWN_VIEW_FIELD", "view_name": patch.get("name"), "missing_fields": missing_gantt_fields})
|
|
1510
|
+
translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.get("filters") or [])
|
|
1511
|
+
if filter_issues:
|
|
1512
|
+
blocking_issues.extend(
|
|
1513
|
+
{
|
|
1514
|
+
**issue,
|
|
1515
|
+
"view_name": patch.get("name"),
|
|
1516
|
+
}
|
|
1517
|
+
for issue in filter_issues
|
|
1518
|
+
)
|
|
1519
|
+
if translated_filters:
|
|
1520
|
+
patch["filters"] = [dict(rule) for rule in (patch.get("filters") or [])]
|
|
662
1521
|
normalized_args = {
|
|
663
1522
|
"app_key": request.app_key,
|
|
664
1523
|
"upsert_views": upsert_views,
|
|
@@ -671,7 +1530,11 @@ class AiBuilderFacade:
|
|
|
671
1530
|
"message": "view plan has blocking issues" if blocking_issues else "planned view patch",
|
|
672
1531
|
"normalized_args": normalized_args,
|
|
673
1532
|
"missing_fields": [],
|
|
674
|
-
"allowed_values": {
|
|
1533
|
+
"allowed_values": {
|
|
1534
|
+
"view_types": [member.value for member in PublicViewType],
|
|
1535
|
+
"presets": [preset.value for preset in ViewsPreset],
|
|
1536
|
+
"view.filter.operator": [member.value for member in ViewFilterOperator],
|
|
1537
|
+
},
|
|
675
1538
|
"details": {},
|
|
676
1539
|
"request_id": None,
|
|
677
1540
|
"views_diff_preview": {
|
|
@@ -731,6 +1594,7 @@ class AiBuilderFacade:
|
|
|
731
1594
|
package_tag_id: int | None = None,
|
|
732
1595
|
app_name: str = "",
|
|
733
1596
|
create_if_missing: bool = False,
|
|
1597
|
+
publish: bool = True,
|
|
734
1598
|
add_fields: list[FieldPatch],
|
|
735
1599
|
update_fields: list[FieldUpdatePatch],
|
|
736
1600
|
remove_fields: list[FieldRemovePatch],
|
|
@@ -740,6 +1604,7 @@ class AiBuilderFacade:
|
|
|
740
1604
|
"package_tag_id": package_tag_id,
|
|
741
1605
|
"app_name": app_name,
|
|
742
1606
|
"create_if_missing": create_if_missing,
|
|
1607
|
+
"publish": publish,
|
|
743
1608
|
"add_fields": [patch.model_dump(mode="json") for patch in add_fields],
|
|
744
1609
|
"update_fields": [patch.model_dump(mode="json") for patch in update_fields],
|
|
745
1610
|
"remove_fields": [patch.model_dump(mode="json") for patch in remove_fields],
|
|
@@ -757,19 +1622,26 @@ class AiBuilderFacade:
|
|
|
757
1622
|
return resolved
|
|
758
1623
|
target = ResolvedApp(
|
|
759
1624
|
app_key=str(resolved["app_key"]),
|
|
760
|
-
app_name=str(resolved["app_name"]),
|
|
761
|
-
tag_ids=_coerce_int_list(resolved.get("tag_ids")),
|
|
762
|
-
)
|
|
763
|
-
schema = self.apps.app_get_form_schema(
|
|
764
|
-
profile=profile,
|
|
765
|
-
app_key=target.app_key,
|
|
766
|
-
form_type=1,
|
|
767
|
-
being_draft=True,
|
|
768
|
-
being_apply=None,
|
|
769
|
-
audit_node_id=None,
|
|
770
|
-
include_raw=True,
|
|
1625
|
+
app_name=str(resolved["app_name"]),
|
|
1626
|
+
tag_ids=_coerce_int_list(resolved.get("tag_ids")),
|
|
771
1627
|
)
|
|
772
|
-
|
|
1628
|
+
schema_readback_delayed = False
|
|
1629
|
+
try:
|
|
1630
|
+
schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=target.app_key)
|
|
1631
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1632
|
+
api_error = _coerce_api_error(error)
|
|
1633
|
+
if not bool(resolved.get("created")) or api_error.http_status != 404:
|
|
1634
|
+
return _failed_from_api_error(
|
|
1635
|
+
"SCHEMA_READBACK_FAILED",
|
|
1636
|
+
api_error,
|
|
1637
|
+
normalized_args=normalized_args,
|
|
1638
|
+
allowed_values={"field_types": [item.value for item in PublicFieldType]},
|
|
1639
|
+
details={"app_key": target.app_key},
|
|
1640
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": target.app_key}},
|
|
1641
|
+
)
|
|
1642
|
+
schema_result = _empty_schema_result(target.app_name)
|
|
1643
|
+
_schema_source = "synthetic_new_app"
|
|
1644
|
+
schema_readback_delayed = True
|
|
773
1645
|
parsed = _parse_schema(schema_result)
|
|
774
1646
|
current_fields = parsed["fields"]
|
|
775
1647
|
layout = parsed["layout"]
|
|
@@ -834,7 +1706,7 @@ class AiBuilderFacade:
|
|
|
834
1706
|
if not added and not updated and not removed and not bool(resolved.get("created")):
|
|
835
1707
|
tag_ids_after = _coerce_int_list((self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}).get("tagIds"))
|
|
836
1708
|
package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
|
|
837
|
-
|
|
1709
|
+
response = {
|
|
838
1710
|
"status": "success",
|
|
839
1711
|
"error_code": None,
|
|
840
1712
|
"recoverable": False,
|
|
@@ -854,6 +1726,7 @@ class AiBuilderFacade:
|
|
|
854
1726
|
"tag_ids_after": tag_ids_after,
|
|
855
1727
|
"package_attached": package_attached,
|
|
856
1728
|
}
|
|
1729
|
+
return self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
|
|
857
1730
|
|
|
858
1731
|
payload = _build_form_payload_from_fields(
|
|
859
1732
|
title=schema_result.get("formTitle") or target.app_name,
|
|
@@ -861,6 +1734,11 @@ class AiBuilderFacade:
|
|
|
861
1734
|
fields=current_fields,
|
|
862
1735
|
layout=layout,
|
|
863
1736
|
)
|
|
1737
|
+
payload["editVersionNo"] = self._resolve_form_edit_version(
|
|
1738
|
+
profile=profile,
|
|
1739
|
+
app_key=target.app_key,
|
|
1740
|
+
current_schema=schema_result,
|
|
1741
|
+
)
|
|
864
1742
|
try:
|
|
865
1743
|
self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
|
|
866
1744
|
except (QingflowApiError, RuntimeError) as error:
|
|
@@ -876,13 +1754,8 @@ class AiBuilderFacade:
|
|
|
876
1754
|
},
|
|
877
1755
|
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
|
|
878
1756
|
)
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
tag_ids_after = _coerce_int_list((self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}).get("tagIds"))
|
|
882
|
-
verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
|
|
883
|
-
package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
|
|
884
|
-
return {
|
|
885
|
-
"status": "success" if verification_ok and (package_attached is not False) else "partial_success",
|
|
1757
|
+
response = {
|
|
1758
|
+
"status": "success",
|
|
886
1759
|
"error_code": None,
|
|
887
1760
|
"recoverable": False,
|
|
888
1761
|
"message": "applied schema patch",
|
|
@@ -891,21 +1764,11 @@ class AiBuilderFacade:
|
|
|
891
1764
|
"allowed_values": {"field_types": [item.value for item in PublicFieldType]},
|
|
892
1765
|
"details": {},
|
|
893
1766
|
"request_id": None,
|
|
894
|
-
"suggested_next_call": None
|
|
895
|
-
if package_attached is not False
|
|
896
|
-
else {
|
|
897
|
-
"tool_name": "package_attach_app",
|
|
898
|
-
"arguments": {
|
|
899
|
-
"profile": profile,
|
|
900
|
-
"tag_id": package_tag_id,
|
|
901
|
-
"app_key": target.app_key,
|
|
902
|
-
"app_title": app_name or target.app_name,
|
|
903
|
-
},
|
|
904
|
-
},
|
|
1767
|
+
"suggested_next_call": None,
|
|
905
1768
|
"noop": False,
|
|
906
1769
|
"verification": {
|
|
907
|
-
"fields_verified":
|
|
908
|
-
"package_attached":
|
|
1770
|
+
"fields_verified": False,
|
|
1771
|
+
"package_attached": None,
|
|
909
1772
|
},
|
|
910
1773
|
"app_key": target.app_key,
|
|
911
1774
|
"created": bool(resolved.get("created")),
|
|
@@ -914,10 +1777,71 @@ class AiBuilderFacade:
|
|
|
914
1777
|
"updated": updated,
|
|
915
1778
|
"removed": removed,
|
|
916
1779
|
},
|
|
917
|
-
"verified":
|
|
918
|
-
"tag_ids_after":
|
|
919
|
-
"package_attached":
|
|
1780
|
+
"verified": False,
|
|
1781
|
+
"tag_ids_after": [],
|
|
1782
|
+
"package_attached": None,
|
|
920
1783
|
}
|
|
1784
|
+
if schema_readback_delayed:
|
|
1785
|
+
response["verification"]["schema_readback_delayed"] = True
|
|
1786
|
+
response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
|
|
1787
|
+
verification_ok = False
|
|
1788
|
+
tag_ids_after: list[int] = []
|
|
1789
|
+
package_attached: bool | None = None
|
|
1790
|
+
verification_error: QingflowApiError | None = None
|
|
1791
|
+
try:
|
|
1792
|
+
verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
|
|
1793
|
+
verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
|
|
1794
|
+
verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
|
|
1795
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1796
|
+
verification_error = _coerce_api_error(error)
|
|
1797
|
+
verification_ok = False
|
|
1798
|
+
try:
|
|
1799
|
+
base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
|
|
1800
|
+
tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
|
|
1801
|
+
package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
|
|
1802
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1803
|
+
base_error = _coerce_api_error(error)
|
|
1804
|
+
if verification_error is None:
|
|
1805
|
+
verification_error = base_error
|
|
1806
|
+
tag_ids_after = []
|
|
1807
|
+
package_attached = None if package_tag_id is None else False
|
|
1808
|
+
response["verification"]["fields_verified"] = verification_ok
|
|
1809
|
+
response["verification"]["package_attached"] = package_attached
|
|
1810
|
+
response["verified"] = verification_ok
|
|
1811
|
+
response["tag_ids_after"] = tag_ids_after
|
|
1812
|
+
response["package_attached"] = package_attached
|
|
1813
|
+
if package_attached is False:
|
|
1814
|
+
response["suggested_next_call"] = {
|
|
1815
|
+
"tool_name": "package_attach_app",
|
|
1816
|
+
"arguments": {
|
|
1817
|
+
"profile": profile,
|
|
1818
|
+
"tag_id": package_tag_id,
|
|
1819
|
+
"app_key": target.app_key,
|
|
1820
|
+
"app_title": app_name or target.app_name,
|
|
1821
|
+
},
|
|
1822
|
+
}
|
|
1823
|
+
publish_failed = bool(response.get("publish_requested")) and not bool(response.get("published"))
|
|
1824
|
+
if verification_ok and package_attached is not False and not publish_failed:
|
|
1825
|
+
response["status"] = "success"
|
|
1826
|
+
else:
|
|
1827
|
+
response["status"] = "partial_success"
|
|
1828
|
+
if verification_error is not None:
|
|
1829
|
+
response["recoverable"] = True
|
|
1830
|
+
response["error_code"] = response.get("error_code") or (
|
|
1831
|
+
"READBACK_PENDING" if verification_error.http_status == 404 else "READBACK_FAILED"
|
|
1832
|
+
)
|
|
1833
|
+
response["message"] = f"{response.get('message') or 'apply succeeded'}; readback pending"
|
|
1834
|
+
response["request_id"] = response.get("request_id") or verification_error.request_id
|
|
1835
|
+
details = response.get("details")
|
|
1836
|
+
if not isinstance(details, dict):
|
|
1837
|
+
details = {}
|
|
1838
|
+
response["details"] = details
|
|
1839
|
+
details["verification_error"] = {
|
|
1840
|
+
"message": verification_error.message,
|
|
1841
|
+
"http_status": verification_error.http_status,
|
|
1842
|
+
"backend_code": verification_error.backend_code,
|
|
1843
|
+
}
|
|
1844
|
+
return response
|
|
921
1845
|
|
|
922
1846
|
def app_layout_apply(
|
|
923
1847
|
self,
|
|
@@ -926,28 +1850,47 @@ class AiBuilderFacade:
|
|
|
926
1850
|
app_key: str,
|
|
927
1851
|
mode: LayoutApplyMode = LayoutApplyMode.merge,
|
|
928
1852
|
sections: list[LayoutSectionPatch],
|
|
1853
|
+
publish: bool = True,
|
|
929
1854
|
) -> JSONObject:
|
|
1855
|
+
requested_sections = [section.model_dump(mode="json") for section in sections]
|
|
1856
|
+
try:
|
|
1857
|
+
schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
1858
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
1859
|
+
api_error = _coerce_api_error(error)
|
|
1860
|
+
return _failed_from_api_error(
|
|
1861
|
+
"LAYOUT_READ_FAILED",
|
|
1862
|
+
api_error,
|
|
1863
|
+
normalized_args={
|
|
1864
|
+
"app_key": app_key,
|
|
1865
|
+
"mode": mode.value,
|
|
1866
|
+
"sections": requested_sections,
|
|
1867
|
+
"publish": publish,
|
|
1868
|
+
},
|
|
1869
|
+
details={"app_key": app_key},
|
|
1870
|
+
suggested_next_call={"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1871
|
+
)
|
|
1872
|
+
parsed = _parse_schema(schema_result)
|
|
1873
|
+
current_fields = parsed["fields"]
|
|
1874
|
+
requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
|
|
930
1875
|
normalized_args = {
|
|
931
1876
|
"app_key": app_key,
|
|
932
1877
|
"mode": mode.value,
|
|
933
|
-
"sections":
|
|
1878
|
+
"sections": requested_sections,
|
|
1879
|
+
"publish": publish,
|
|
934
1880
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
schema_result = schema.get("result") if isinstance(schema.get("result"), dict) else {}
|
|
945
|
-
parsed = _parse_schema(schema_result)
|
|
946
|
-
current_fields = parsed["fields"]
|
|
1881
|
+
if missing_selectors:
|
|
1882
|
+
return _failed(
|
|
1883
|
+
"UNKNOWN_LAYOUT_FIELD",
|
|
1884
|
+
"layout references unknown field selectors",
|
|
1885
|
+
normalized_args=normalized_args,
|
|
1886
|
+
details={"unknown_selectors": missing_selectors},
|
|
1887
|
+
missing_fields=[str(item) for item in missing_selectors],
|
|
1888
|
+
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1889
|
+
)
|
|
947
1890
|
fields_by_name = {field["name"]: field for field in current_fields}
|
|
948
1891
|
seen: list[str] = []
|
|
949
|
-
for section in
|
|
950
|
-
for row in section.rows:
|
|
1892
|
+
for section in requested_sections:
|
|
1893
|
+
for row in section.get("rows", []):
|
|
951
1894
|
for field_name in row:
|
|
952
1895
|
if field_name not in fields_by_name:
|
|
953
1896
|
return _failed(
|
|
@@ -967,7 +1910,6 @@ class AiBuilderFacade:
|
|
|
967
1910
|
)
|
|
968
1911
|
seen.append(field_name)
|
|
969
1912
|
expected = {field["name"] for field in current_fields}
|
|
970
|
-
requested_sections = [section.model_dump(mode="json") for section in sections]
|
|
971
1913
|
if mode == LayoutApplyMode.replace and set(seen) != expected:
|
|
972
1914
|
missing = sorted(expected.difference(seen))
|
|
973
1915
|
return _failed(
|
|
@@ -999,7 +1941,7 @@ class AiBuilderFacade:
|
|
|
999
1941
|
else merged["layout"]
|
|
1000
1942
|
)
|
|
1001
1943
|
if _layouts_equal(parsed["layout"], target_layout):
|
|
1002
|
-
|
|
1944
|
+
response = {
|
|
1003
1945
|
"status": "success",
|
|
1004
1946
|
"error_code": None,
|
|
1005
1947
|
"recoverable": False,
|
|
@@ -1022,10 +1964,16 @@ class AiBuilderFacade:
|
|
|
1022
1964
|
},
|
|
1023
1965
|
"verified": True,
|
|
1024
1966
|
}
|
|
1967
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
1025
1968
|
payload = _build_form_payload_from_existing_schema(
|
|
1026
1969
|
current_schema=schema_result,
|
|
1027
1970
|
layout=target_layout,
|
|
1028
1971
|
)
|
|
1972
|
+
payload["editVersionNo"] = self._resolve_form_edit_version(
|
|
1973
|
+
profile=profile,
|
|
1974
|
+
app_key=app_key,
|
|
1975
|
+
current_schema=schema_result,
|
|
1976
|
+
)
|
|
1029
1977
|
applied_layout = target_layout
|
|
1030
1978
|
fallback_applied = None
|
|
1031
1979
|
try:
|
|
@@ -1038,6 +1986,11 @@ class AiBuilderFacade:
|
|
|
1038
1986
|
current_schema=schema_result,
|
|
1039
1987
|
layout=flattened_layout,
|
|
1040
1988
|
)
|
|
1989
|
+
fallback_payload["editVersionNo"] = self._resolve_form_edit_version(
|
|
1990
|
+
profile=profile,
|
|
1991
|
+
app_key=app_key,
|
|
1992
|
+
current_schema=schema_result,
|
|
1993
|
+
)
|
|
1041
1994
|
try:
|
|
1042
1995
|
self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=fallback_payload)
|
|
1043
1996
|
applied_layout = flattened_layout
|
|
@@ -1071,8 +2024,33 @@ class AiBuilderFacade:
|
|
|
1071
2024
|
},
|
|
1072
2025
|
suggested_next_call={"tool_name": "app_layout_plan", "arguments": {"profile": profile, **normalized_args}},
|
|
1073
2026
|
)
|
|
1074
|
-
verified = self.
|
|
1075
|
-
|
|
2027
|
+
verified = self.app_read_layout_summary(profile=profile, app_key=app_key)
|
|
2028
|
+
if verified.get("status") == "failed":
|
|
2029
|
+
response = {
|
|
2030
|
+
"status": "partial_success",
|
|
2031
|
+
"error_code": "LAYOUT_READBACK_PENDING",
|
|
2032
|
+
"recoverable": True,
|
|
2033
|
+
"message": "applied app layout; layout readback pending",
|
|
2034
|
+
"normalized_args": normalized_args,
|
|
2035
|
+
"missing_fields": [],
|
|
2036
|
+
"allowed_values": {"modes": ["merge", "replace"]},
|
|
2037
|
+
"details": {},
|
|
2038
|
+
"request_id": verified.get("request_id"),
|
|
2039
|
+
"suggested_next_call": {"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2040
|
+
"noop": False,
|
|
2041
|
+
"verification": {"layout_verified": False, "layout_read_unavailable": True},
|
|
2042
|
+
"app_key": app_key,
|
|
2043
|
+
"layout_diff": {
|
|
2044
|
+
"mode": mode.value,
|
|
2045
|
+
"replaced": mode == LayoutApplyMode.replace,
|
|
2046
|
+
"merged": mode == LayoutApplyMode.merge,
|
|
2047
|
+
"auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
|
|
2048
|
+
"fallback_applied": fallback_applied,
|
|
2049
|
+
},
|
|
2050
|
+
"verified": False,
|
|
2051
|
+
}
|
|
2052
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
2053
|
+
response = {
|
|
1076
2054
|
"status": "partial_success" if fallback_applied else "success",
|
|
1077
2055
|
"error_code": None,
|
|
1078
2056
|
"recoverable": False,
|
|
@@ -1084,7 +2062,7 @@ class AiBuilderFacade:
|
|
|
1084
2062
|
"request_id": None,
|
|
1085
2063
|
"suggested_next_call": None,
|
|
1086
2064
|
"noop": False,
|
|
1087
|
-
"verification": {"layout_verified": verified["
|
|
2065
|
+
"verification": {"layout_verified": verified["sections"] == applied_layout.get("sections", [])},
|
|
1088
2066
|
"app_key": app_key,
|
|
1089
2067
|
"layout_diff": {
|
|
1090
2068
|
"mode": mode.value,
|
|
@@ -1093,8 +2071,9 @@ class AiBuilderFacade:
|
|
|
1093
2071
|
"auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
|
|
1094
2072
|
"fallback_applied": fallback_applied,
|
|
1095
2073
|
},
|
|
1096
|
-
"verified": verified["
|
|
2074
|
+
"verified": verified["sections"] == applied_layout.get("sections", []),
|
|
1097
2075
|
}
|
|
2076
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
1098
2077
|
|
|
1099
2078
|
def app_flow_apply(
|
|
1100
2079
|
self,
|
|
@@ -1104,12 +2083,14 @@ class AiBuilderFacade:
|
|
|
1104
2083
|
nodes: list[dict[str, Any]],
|
|
1105
2084
|
transitions: list[dict[str, Any]],
|
|
1106
2085
|
mode: str = "replace",
|
|
2086
|
+
publish: bool = True,
|
|
1107
2087
|
) -> JSONObject:
|
|
1108
2088
|
normalized_args = {
|
|
1109
2089
|
"app_key": app_key,
|
|
1110
2090
|
"mode": mode,
|
|
1111
2091
|
"nodes": nodes,
|
|
1112
2092
|
"transitions": transitions,
|
|
2093
|
+
"publish": publish,
|
|
1113
2094
|
}
|
|
1114
2095
|
if mode != "replace":
|
|
1115
2096
|
return _failed(
|
|
@@ -1119,25 +2100,65 @@ class AiBuilderFacade:
|
|
|
1119
2100
|
allowed_values={"modes": ["replace"]},
|
|
1120
2101
|
suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}},
|
|
1121
2102
|
)
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
profile=profile,
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
2103
|
+
try:
|
|
2104
|
+
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
|
|
2105
|
+
schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
2106
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2107
|
+
api_error = _coerce_api_error(error)
|
|
2108
|
+
return _failed_from_api_error(
|
|
2109
|
+
"FLOW_READ_FAILED",
|
|
2110
|
+
api_error,
|
|
2111
|
+
normalized_args=normalized_args,
|
|
2112
|
+
details={"app_key": app_key},
|
|
2113
|
+
suggested_next_call={"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2114
|
+
)
|
|
1132
2115
|
entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
|
|
1133
|
-
|
|
2116
|
+
current_fields = _parse_schema(schema)["fields"]
|
|
2117
|
+
normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
|
|
2118
|
+
public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
|
|
2119
|
+
normalized_args["nodes"] = public_nodes
|
|
2120
|
+
if resolution_issues:
|
|
2121
|
+
first_issue = resolution_issues[0]
|
|
2122
|
+
suggested_call = None
|
|
2123
|
+
if first_issue.get("kind", "").startswith("role"):
|
|
2124
|
+
suggested_call = {"tool_name": "role_search", "arguments": {"profile": profile, "keyword": first_issue.get("value") or ""}}
|
|
2125
|
+
elif first_issue.get("kind", "").startswith("member"):
|
|
2126
|
+
suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
|
|
2127
|
+
elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
|
|
2128
|
+
suggested_call = {"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}}
|
|
2129
|
+
return _failed(
|
|
2130
|
+
first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
|
|
2131
|
+
"workflow contains unresolved assignees or field permissions",
|
|
2132
|
+
normalized_args=normalized_args,
|
|
2133
|
+
details={"issues": resolution_issues},
|
|
2134
|
+
suggested_next_call=suggested_call,
|
|
2135
|
+
)
|
|
2136
|
+
assignee_required_nodes = [
|
|
2137
|
+
node.get("id")
|
|
2138
|
+
for node in normalized_nodes
|
|
2139
|
+
if str(node.get("type") or "") in {"approve", "fill", "copy"}
|
|
2140
|
+
and not (
|
|
2141
|
+
(node.get("assignees") or {}).get("role_entries")
|
|
2142
|
+
or (node.get("assignees") or {}).get("member_uids")
|
|
2143
|
+
)
|
|
2144
|
+
]
|
|
2145
|
+
if assignee_required_nodes:
|
|
2146
|
+
return _failed(
|
|
2147
|
+
"FLOW_ASSIGNEE_REQUIRED",
|
|
2148
|
+
"workflow approval/fill/copy nodes must declare at least one role or member assignee",
|
|
2149
|
+
normalized_args=normalized_args,
|
|
2150
|
+
details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
|
|
2151
|
+
suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
|
|
2152
|
+
)
|
|
2153
|
+
workflow_spec = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
|
|
1134
2154
|
if workflow_spec.get("status") == "failed":
|
|
1135
2155
|
workflow_spec["normalized_args"] = normalized_args
|
|
1136
2156
|
workflow_spec.setdefault("request_id", None)
|
|
1137
2157
|
workflow_spec["suggested_next_call"] = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
|
|
1138
2158
|
return workflow_spec
|
|
1139
|
-
desired_node_count = len([node for node in
|
|
1140
|
-
|
|
2159
|
+
desired_node_count = len([node for node in normalized_nodes if node.get("type") != "end"])
|
|
2160
|
+
current_workflow, _workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2161
|
+
current_node_count = len(_summarize_workflow_nodes(current_workflow))
|
|
1141
2162
|
if current_node_count == desired_node_count and desired_node_count > 0:
|
|
1142
2163
|
# Lightweight idempotency check for repeat submissions of same simple graph.
|
|
1143
2164
|
pass
|
|
@@ -1149,42 +2170,57 @@ class AiBuilderFacade:
|
|
|
1149
2170
|
os.environ["QINGFLOW_MCP_BUILD_HOME"] = temporary_build_home
|
|
1150
2171
|
try:
|
|
1151
2172
|
assembly = BuildAssemblyStore.open(build_id=build_id, create=True)
|
|
2173
|
+
manifest = default_manifest()
|
|
2174
|
+
manifest["solution_name"] = base.get("formTitle") or app_key
|
|
2175
|
+
manifest["preferences"]["create_package"] = False
|
|
2176
|
+
manifest["preferences"]["create_portal"] = False
|
|
2177
|
+
manifest["preferences"]["create_navigation"] = False
|
|
2178
|
+
manifest["entities"] = [entity]
|
|
2179
|
+
assembly.set_manifest(manifest)
|
|
2180
|
+
artifacts = default_artifacts()
|
|
2181
|
+
artifacts["apps"][entity["entity_id"]] = {"app_key": app_key}
|
|
2182
|
+
assembly.set_artifacts(artifacts)
|
|
2183
|
+
flow_stage_spec = {
|
|
2184
|
+
"solution_name": manifest["solution_name"],
|
|
2185
|
+
"entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
|
|
2186
|
+
}
|
|
2187
|
+
assembly.set_stage_spec("app_flow", flow_stage_spec)
|
|
2188
|
+
stage = self.solutions.solution_build_flow(
|
|
2189
|
+
profile=profile,
|
|
2190
|
+
mode="apply",
|
|
2191
|
+
build_id=build_id,
|
|
2192
|
+
flow_spec=flow_stage_spec,
|
|
2193
|
+
publish=False,
|
|
2194
|
+
run_label=None,
|
|
2195
|
+
repair_patch={},
|
|
2196
|
+
)
|
|
1152
2197
|
finally:
|
|
1153
2198
|
if previous_build_home is None:
|
|
1154
2199
|
os.environ.pop("QINGFLOW_MCP_BUILD_HOME", None)
|
|
1155
|
-
manifest = default_manifest()
|
|
1156
|
-
manifest["solution_name"] = base.get("formTitle") or app_key
|
|
1157
|
-
manifest["preferences"]["create_package"] = False
|
|
1158
|
-
manifest["preferences"]["create_portal"] = False
|
|
1159
|
-
manifest["preferences"]["create_navigation"] = False
|
|
1160
|
-
manifest["entities"] = [entity]
|
|
1161
|
-
assembly.set_manifest(manifest)
|
|
1162
|
-
artifacts = default_artifacts()
|
|
1163
|
-
artifacts["apps"][entity["entity_id"]] = {"app_key": app_key}
|
|
1164
|
-
assembly.set_artifacts(artifacts)
|
|
1165
|
-
stage = self.solutions.solution_build_flow(
|
|
1166
|
-
profile=profile,
|
|
1167
|
-
mode="apply",
|
|
1168
|
-
build_id=build_id,
|
|
1169
|
-
flow_spec={
|
|
1170
|
-
"solution_name": manifest["solution_name"],
|
|
1171
|
-
"entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
|
|
1172
|
-
},
|
|
1173
|
-
publish=False,
|
|
1174
|
-
run_label=None,
|
|
1175
|
-
repair_patch={},
|
|
1176
|
-
)
|
|
1177
2200
|
if stage.get("status") != "success":
|
|
1178
2201
|
failed = _normalize_flow_stage_failure(stage, profile=profile, app_key=app_key, entity=entity)
|
|
1179
2202
|
failed["normalized_args"] = normalized_args
|
|
1180
|
-
|
|
2203
|
+
suggested_next_call = failed.get("suggested_next_call")
|
|
2204
|
+
if not isinstance(suggested_next_call, dict):
|
|
2205
|
+
suggested_next_call = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
|
|
2206
|
+
elif suggested_next_call.get("tool_name") == "app_flow_plan":
|
|
2207
|
+
arguments = suggested_next_call.get("arguments")
|
|
2208
|
+
if not isinstance(arguments, dict):
|
|
2209
|
+
arguments = {}
|
|
2210
|
+
arguments.setdefault("profile", profile)
|
|
2211
|
+
arguments.setdefault("app_key", app_key)
|
|
2212
|
+
arguments.setdefault("mode", mode)
|
|
2213
|
+
arguments.setdefault("nodes", public_nodes)
|
|
2214
|
+
arguments.setdefault("transitions", transitions)
|
|
2215
|
+
suggested_next_call["arguments"] = arguments
|
|
2216
|
+
failed["suggested_next_call"] = suggested_next_call
|
|
1181
2217
|
return failed
|
|
1182
|
-
verified_nodes = self.
|
|
1183
|
-
|
|
1184
|
-
"status": "success",
|
|
2218
|
+
verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2219
|
+
response = {
|
|
2220
|
+
"status": "success" if bool(verified_nodes) or not verified_nodes_unavailable else "partial_success",
|
|
1185
2221
|
"error_code": None,
|
|
1186
|
-
"recoverable":
|
|
1187
|
-
"message": "applied workflow patch",
|
|
2222
|
+
"recoverable": bool(verified_nodes_unavailable),
|
|
2223
|
+
"message": "applied workflow patch" if not verified_nodes_unavailable else "applied workflow patch; flow readback pending",
|
|
1188
2224
|
"normalized_args": normalized_args,
|
|
1189
2225
|
"missing_fields": [],
|
|
1190
2226
|
"allowed_values": {"modes": ["replace"]},
|
|
@@ -1192,11 +2228,15 @@ class AiBuilderFacade:
|
|
|
1192
2228
|
"request_id": None,
|
|
1193
2229
|
"suggested_next_call": None,
|
|
1194
2230
|
"noop": False,
|
|
1195
|
-
"verification": {"workflow_verified": bool(verified_nodes)},
|
|
2231
|
+
"verification": {"workflow_verified": bool(verified_nodes), "workflow_read_unavailable": verified_nodes_unavailable},
|
|
1196
2232
|
"app_key": app_key,
|
|
1197
2233
|
"flow_diff": {"mode": "replace", "node_count": desired_node_count},
|
|
1198
2234
|
"verified": bool(verified_nodes),
|
|
1199
2235
|
}
|
|
2236
|
+
if verified_nodes_unavailable:
|
|
2237
|
+
response["error_code"] = "FLOW_READBACK_PENDING"
|
|
2238
|
+
response["suggested_next_call"] = {"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}}
|
|
2239
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
1200
2240
|
|
|
1201
2241
|
def app_views_apply(
|
|
1202
2242
|
self,
|
|
@@ -1205,21 +2245,23 @@ class AiBuilderFacade:
|
|
|
1205
2245
|
app_key: str,
|
|
1206
2246
|
upsert_views: list[ViewUpsertPatch],
|
|
1207
2247
|
remove_views: list[str],
|
|
2248
|
+
publish: bool = True,
|
|
1208
2249
|
) -> JSONObject:
|
|
1209
2250
|
normalized_args = {
|
|
1210
2251
|
"app_key": app_key,
|
|
1211
2252
|
"upsert_views": [patch.model_dump(mode="json") for patch in upsert_views],
|
|
1212
2253
|
"remove_views": list(remove_views),
|
|
2254
|
+
"publish": publish,
|
|
1213
2255
|
}
|
|
1214
2256
|
if not upsert_views and not remove_views:
|
|
1215
|
-
|
|
2257
|
+
response = {
|
|
1216
2258
|
"status": "success",
|
|
1217
2259
|
"error_code": None,
|
|
1218
2260
|
"recoverable": False,
|
|
1219
2261
|
"message": "no view changes requested",
|
|
1220
2262
|
"normalized_args": normalized_args,
|
|
1221
2263
|
"missing_fields": [],
|
|
1222
|
-
"allowed_values": {"view_types": [
|
|
2264
|
+
"allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
1223
2265
|
"details": {},
|
|
1224
2266
|
"request_id": None,
|
|
1225
2267
|
"suggested_next_call": None,
|
|
@@ -1229,41 +2271,72 @@ class AiBuilderFacade:
|
|
|
1229
2271
|
"views_diff": {"created": [], "updated": [], "removed": []},
|
|
1230
2272
|
"verified": True,
|
|
1231
2273
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
profile=profile,
|
|
1235
|
-
app_key=app_key
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
2274
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
2275
|
+
try:
|
|
2276
|
+
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
|
|
2277
|
+
schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
2278
|
+
existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
|
|
2279
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2280
|
+
api_error = _coerce_api_error(error)
|
|
2281
|
+
return _failed_from_api_error(
|
|
2282
|
+
"VIEWS_READ_FAILED",
|
|
2283
|
+
api_error,
|
|
2284
|
+
normalized_args=normalized_args,
|
|
2285
|
+
details={"app_key": app_key},
|
|
2286
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2287
|
+
)
|
|
2288
|
+
existing_views = existing_views or []
|
|
2289
|
+
existing_by_key: dict[str, dict[str, Any]] = {}
|
|
2290
|
+
existing_by_name: dict[str, list[dict[str, Any]]] = {}
|
|
1244
2291
|
for view in existing_views if isinstance(existing_views, list) else []:
|
|
1245
2292
|
if not isinstance(view, dict):
|
|
1246
2293
|
continue
|
|
1247
|
-
name =
|
|
1248
|
-
key =
|
|
2294
|
+
name = _extract_view_name(view)
|
|
2295
|
+
key = _extract_view_key(view)
|
|
1249
2296
|
if name and key:
|
|
1250
|
-
|
|
2297
|
+
existing_by_key[key] = view
|
|
2298
|
+
existing_by_name.setdefault(name, []).append(view)
|
|
1251
2299
|
parsed_schema = _parse_schema(schema)
|
|
1252
2300
|
field_names = {field["name"] for field in parsed_schema["fields"]}
|
|
2301
|
+
current_fields_by_name = {
|
|
2302
|
+
str(field.get("name") or ""): field
|
|
2303
|
+
for field in parsed_schema["fields"]
|
|
2304
|
+
if isinstance(field, dict) and str(field.get("name") or "")
|
|
2305
|
+
}
|
|
1253
2306
|
removed: list[str] = []
|
|
2307
|
+
view_results: list[dict[str, Any]] = []
|
|
1254
2308
|
for name in remove_views:
|
|
1255
|
-
|
|
1256
|
-
if
|
|
2309
|
+
matches = existing_by_name.get(name, [])
|
|
2310
|
+
if len(matches) > 1:
|
|
2311
|
+
return _failed(
|
|
2312
|
+
"AMBIGUOUS_VIEW",
|
|
2313
|
+
"multiple views matched remove request; use app_read_views_summary and resolve duplicates before removing by name",
|
|
2314
|
+
normalized_args=normalized_args,
|
|
2315
|
+
details={
|
|
2316
|
+
"app_key": app_key,
|
|
2317
|
+
"view_name": name,
|
|
2318
|
+
"matches": [
|
|
2319
|
+
{"name": _extract_view_name(view), "view_key": _extract_view_key(view), "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))}
|
|
2320
|
+
for view in matches
|
|
2321
|
+
],
|
|
2322
|
+
},
|
|
2323
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2324
|
+
)
|
|
2325
|
+
if len(matches) == 1:
|
|
2326
|
+
key = _extract_view_key(matches[0])
|
|
1257
2327
|
self.views.view_delete(profile=profile, viewgraph_key=key)
|
|
1258
2328
|
removed.append(name)
|
|
1259
2329
|
existing_by_name.pop(name, None)
|
|
2330
|
+
existing_by_key.pop(key, None)
|
|
2331
|
+
view_results.append({"name": name, "view_key": key, "type": None, "status": "removed"})
|
|
1260
2332
|
created: list[str] = []
|
|
1261
2333
|
updated: list[str] = []
|
|
2334
|
+
failed_views: list[dict[str, Any]] = []
|
|
1262
2335
|
existing_view_list = [
|
|
1263
2336
|
view
|
|
1264
2337
|
for view in (existing_views if isinstance(existing_views, list) else [])
|
|
1265
2338
|
if isinstance(view, dict)
|
|
1266
|
-
and
|
|
2339
|
+
and _extract_view_name(view) not in remove_views
|
|
1267
2340
|
]
|
|
1268
2341
|
for ordinal, patch in enumerate(upsert_views, start=1):
|
|
1269
2342
|
missing_columns = [name for name in patch.columns if name not in field_names]
|
|
@@ -1293,7 +2366,69 @@ class AiBuilderFacade:
|
|
|
1293
2366
|
missing_fields=[patch.group_by],
|
|
1294
2367
|
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1295
2368
|
)
|
|
1296
|
-
|
|
2369
|
+
for gantt_field_name in (patch.start_field, patch.end_field, patch.title_field):
|
|
2370
|
+
if gantt_field_name and gantt_field_name not in field_names:
|
|
2371
|
+
return _failed(
|
|
2372
|
+
"UNKNOWN_VIEW_FIELD",
|
|
2373
|
+
f"gantt configuration references unknown field '{gantt_field_name}'",
|
|
2374
|
+
normalized_args=normalized_args,
|
|
2375
|
+
details={
|
|
2376
|
+
"app_key": app_key,
|
|
2377
|
+
"view_name": patch.name,
|
|
2378
|
+
"missing_fields": [gantt_field_name],
|
|
2379
|
+
},
|
|
2380
|
+
missing_fields=[gantt_field_name],
|
|
2381
|
+
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2382
|
+
)
|
|
2383
|
+
translated_filters, filter_issues = _build_view_filter_groups(current_fields_by_name=current_fields_by_name, filters=patch.filters)
|
|
2384
|
+
if filter_issues:
|
|
2385
|
+
first_issue = filter_issues[0]
|
|
2386
|
+
return _failed(
|
|
2387
|
+
str(first_issue.get("error_code") or "UNKNOWN_VIEW_FIELD"),
|
|
2388
|
+
"view filters reference invalid fields or values",
|
|
2389
|
+
normalized_args=normalized_args,
|
|
2390
|
+
details={
|
|
2391
|
+
"app_key": app_key,
|
|
2392
|
+
"view_name": patch.name,
|
|
2393
|
+
**first_issue,
|
|
2394
|
+
},
|
|
2395
|
+
missing_fields=list(first_issue.get("missing_fields") or []),
|
|
2396
|
+
allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
2397
|
+
suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2398
|
+
)
|
|
2399
|
+
matched_existing_view: dict[str, Any] | None = None
|
|
2400
|
+
existing_key: str | None = None
|
|
2401
|
+
if patch.view_key:
|
|
2402
|
+
matched_existing_view = existing_by_key.get(patch.view_key)
|
|
2403
|
+
if not matched_existing_view:
|
|
2404
|
+
return _failed(
|
|
2405
|
+
"UNKNOWN_VIEW",
|
|
2406
|
+
f"view_key '{patch.view_key}' does not exist on this app",
|
|
2407
|
+
normalized_args=normalized_args,
|
|
2408
|
+
details={"app_key": app_key, "view_key": patch.view_key, "view_name": patch.name},
|
|
2409
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2410
|
+
)
|
|
2411
|
+
existing_key = patch.view_key
|
|
2412
|
+
else:
|
|
2413
|
+
name_matches = existing_by_name.get(patch.name, [])
|
|
2414
|
+
if len(name_matches) > 1:
|
|
2415
|
+
return _failed(
|
|
2416
|
+
"AMBIGUOUS_VIEW",
|
|
2417
|
+
"multiple views share this name; supply view_key to update the exact target",
|
|
2418
|
+
normalized_args=normalized_args,
|
|
2419
|
+
details={
|
|
2420
|
+
"app_key": app_key,
|
|
2421
|
+
"view_name": patch.name,
|
|
2422
|
+
"matches": [
|
|
2423
|
+
{"name": _extract_view_name(view), "view_key": _extract_view_key(view), "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))}
|
|
2424
|
+
for view in name_matches
|
|
2425
|
+
],
|
|
2426
|
+
},
|
|
2427
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2428
|
+
)
|
|
2429
|
+
if len(name_matches) == 1:
|
|
2430
|
+
matched_existing_view = name_matches[0]
|
|
2431
|
+
existing_key = _extract_view_key(matched_existing_view)
|
|
1297
2432
|
created_key: str | None = None
|
|
1298
2433
|
try:
|
|
1299
2434
|
if existing_key:
|
|
@@ -1303,9 +2438,19 @@ class AiBuilderFacade:
|
|
|
1303
2438
|
source_viewgraph_key=existing_key,
|
|
1304
2439
|
schema=schema,
|
|
1305
2440
|
patch=patch,
|
|
2441
|
+
view_filters=translated_filters,
|
|
1306
2442
|
)
|
|
1307
2443
|
self.views.view_update(profile=profile, viewgraph_key=existing_key, payload=payload)
|
|
1308
2444
|
updated.append(patch.name)
|
|
2445
|
+
view_results.append(
|
|
2446
|
+
{
|
|
2447
|
+
"name": patch.name,
|
|
2448
|
+
"view_key": existing_key,
|
|
2449
|
+
"type": patch.type.value,
|
|
2450
|
+
"status": "updated",
|
|
2451
|
+
"expected_filters": deepcopy(translated_filters),
|
|
2452
|
+
}
|
|
2453
|
+
)
|
|
1309
2454
|
else:
|
|
1310
2455
|
template_key = _pick_view_template_key(existing_view_list, desired_type=patch.type.value)
|
|
1311
2456
|
if patch.type.value == "table" and template_key:
|
|
@@ -1317,6 +2462,7 @@ class AiBuilderFacade:
|
|
|
1317
2462
|
source_viewgraph_key=created_key,
|
|
1318
2463
|
schema=schema,
|
|
1319
2464
|
patch=patch,
|
|
2465
|
+
view_filters=translated_filters,
|
|
1320
2466
|
)
|
|
1321
2467
|
self.views.view_update(profile=profile, viewgraph_key=created_key, payload=payload)
|
|
1322
2468
|
else:
|
|
@@ -1326,52 +2472,304 @@ class AiBuilderFacade:
|
|
|
1326
2472
|
schema=schema,
|
|
1327
2473
|
patch=patch,
|
|
1328
2474
|
ordinal=ordinal,
|
|
2475
|
+
view_filters=translated_filters,
|
|
1329
2476
|
)
|
|
1330
2477
|
self.views.view_create(profile=profile, payload=payload)
|
|
1331
2478
|
created.append(patch.name)
|
|
2479
|
+
view_results.append(
|
|
2480
|
+
{
|
|
2481
|
+
"name": patch.name,
|
|
2482
|
+
"view_key": created_key,
|
|
2483
|
+
"type": patch.type.value,
|
|
2484
|
+
"status": "created",
|
|
2485
|
+
"expected_filters": deepcopy(translated_filters),
|
|
2486
|
+
}
|
|
2487
|
+
)
|
|
1332
2488
|
except (QingflowApiError, RuntimeError) as error:
|
|
1333
2489
|
api_error = _coerce_api_error(error)
|
|
2490
|
+
should_retry_minimal = api_error.backend_code == 48104 or (
|
|
2491
|
+
patch.type.value == "table" and bool(patch.filters) and api_error.http_status is not None and api_error.http_status >= 500
|
|
2492
|
+
)
|
|
2493
|
+
if should_retry_minimal:
|
|
2494
|
+
try:
|
|
2495
|
+
if existing_key or created_key:
|
|
2496
|
+
target_key = created_key or existing_key or ""
|
|
2497
|
+
fallback_payload = _build_minimal_view_payload(
|
|
2498
|
+
app_key=app_key,
|
|
2499
|
+
schema=schema,
|
|
2500
|
+
patch=patch,
|
|
2501
|
+
ordinal=ordinal,
|
|
2502
|
+
view_filters=translated_filters,
|
|
2503
|
+
)
|
|
2504
|
+
self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
|
|
2505
|
+
if existing_key:
|
|
2506
|
+
updated.append(patch.name)
|
|
2507
|
+
view_results.append(
|
|
2508
|
+
{
|
|
2509
|
+
"name": patch.name,
|
|
2510
|
+
"view_key": existing_key,
|
|
2511
|
+
"type": patch.type.value,
|
|
2512
|
+
"status": "updated",
|
|
2513
|
+
"fallback_applied": True,
|
|
2514
|
+
"expected_filters": deepcopy(translated_filters),
|
|
2515
|
+
}
|
|
2516
|
+
)
|
|
2517
|
+
else:
|
|
2518
|
+
created.append(patch.name)
|
|
2519
|
+
view_results.append(
|
|
2520
|
+
{
|
|
2521
|
+
"name": patch.name,
|
|
2522
|
+
"view_key": created_key,
|
|
2523
|
+
"type": patch.type.value,
|
|
2524
|
+
"status": "created",
|
|
2525
|
+
"fallback_applied": True,
|
|
2526
|
+
"expected_filters": deepcopy(translated_filters),
|
|
2527
|
+
}
|
|
2528
|
+
)
|
|
2529
|
+
continue
|
|
2530
|
+
fallback_payload = _build_minimal_view_payload(
|
|
2531
|
+
app_key=app_key,
|
|
2532
|
+
schema=schema,
|
|
2533
|
+
patch=patch,
|
|
2534
|
+
ordinal=ordinal,
|
|
2535
|
+
view_filters=translated_filters,
|
|
2536
|
+
)
|
|
2537
|
+
self.views.view_create(profile=profile, payload=fallback_payload)
|
|
2538
|
+
created.append(patch.name)
|
|
2539
|
+
view_results.append(
|
|
2540
|
+
{
|
|
2541
|
+
"name": patch.name,
|
|
2542
|
+
"view_key": created_key,
|
|
2543
|
+
"type": patch.type.value,
|
|
2544
|
+
"status": "created",
|
|
2545
|
+
"fallback_applied": True,
|
|
2546
|
+
"expected_filters": deepcopy(translated_filters),
|
|
2547
|
+
}
|
|
2548
|
+
)
|
|
2549
|
+
continue
|
|
2550
|
+
except (QingflowApiError, RuntimeError) as fallback_error:
|
|
2551
|
+
api_error = _coerce_api_error(fallback_error)
|
|
1334
2552
|
if created_key:
|
|
1335
2553
|
try:
|
|
1336
2554
|
self.views.view_delete(profile=profile, viewgraph_key=created_key)
|
|
1337
2555
|
except Exception:
|
|
1338
2556
|
pass
|
|
1339
|
-
|
|
1340
|
-
"
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
2557
|
+
failure_entry = {
|
|
2558
|
+
"name": patch.name,
|
|
2559
|
+
"view_key": patch.view_key or existing_key or created_key,
|
|
2560
|
+
"type": patch.type.value,
|
|
2561
|
+
"status": "failed",
|
|
2562
|
+
"error_code": "VIEW_APPLY_FAILED",
|
|
2563
|
+
"message": _public_error_message("VIEW_APPLY_FAILED", api_error),
|
|
2564
|
+
"request_id": api_error.request_id,
|
|
2565
|
+
"backend_code": api_error.backend_code,
|
|
2566
|
+
"http_status": None if api_error.http_status == 404 else api_error.http_status,
|
|
2567
|
+
"operation": "update" if existing_key or created_key else "create",
|
|
2568
|
+
"details": {
|
|
1344
2569
|
"app_key": app_key,
|
|
1345
2570
|
"view_name": patch.name,
|
|
1346
2571
|
"view_type": patch.type.value,
|
|
1347
2572
|
"columns": patch.columns,
|
|
1348
2573
|
"group_by": patch.group_by,
|
|
2574
|
+
"filters": [item.model_dump(mode="json") for item in patch.filters],
|
|
2575
|
+
"start_field": patch.start_field,
|
|
2576
|
+
"end_field": patch.end_field,
|
|
2577
|
+
"title_field": patch.title_field,
|
|
2578
|
+
"operation": "update" if existing_key or created_key else "create",
|
|
2579
|
+
"transport_error": {
|
|
2580
|
+
"http_status": api_error.http_status,
|
|
2581
|
+
"backend_code": api_error.backend_code,
|
|
2582
|
+
"category": api_error.category,
|
|
2583
|
+
},
|
|
1349
2584
|
},
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
2585
|
+
}
|
|
2586
|
+
failed_views.append(failure_entry)
|
|
2587
|
+
view_results.append(failure_entry)
|
|
2588
|
+
continue
|
|
2589
|
+
try:
|
|
2590
|
+
verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2591
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2592
|
+
api_error = _coerce_api_error(error)
|
|
2593
|
+
return _failed_from_api_error(
|
|
2594
|
+
"VIEWS_READ_FAILED",
|
|
2595
|
+
api_error,
|
|
2596
|
+
normalized_args=normalized_args,
|
|
2597
|
+
details={"app_key": app_key},
|
|
2598
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2599
|
+
)
|
|
2600
|
+
verified_names = {
|
|
2601
|
+
_extract_view_name(item)
|
|
2602
|
+
for item in (verified_view_result or [])
|
|
2603
|
+
if isinstance(item, dict) and _extract_view_name(item)
|
|
2604
|
+
}
|
|
2605
|
+
verified_by_key = {
|
|
2606
|
+
_extract_view_key(item): item
|
|
2607
|
+
for item in (verified_view_result or [])
|
|
2608
|
+
if isinstance(item, dict) and _extract_view_key(item)
|
|
2609
|
+
}
|
|
2610
|
+
verified_view_keys_by_name: dict[str, list[str]] = {}
|
|
2611
|
+
for item in (verified_view_result or []):
|
|
2612
|
+
if not isinstance(item, dict):
|
|
2613
|
+
continue
|
|
2614
|
+
name = _extract_view_name(item)
|
|
2615
|
+
key = _extract_view_key(item)
|
|
2616
|
+
if name and key:
|
|
2617
|
+
verified_view_keys_by_name.setdefault(name, []).append(key)
|
|
2618
|
+
verification_by_view: list[dict[str, Any]] = []
|
|
2619
|
+
filter_readback_pending = False
|
|
2620
|
+
filter_mismatches: list[dict[str, Any]] = []
|
|
2621
|
+
for item in view_results:
|
|
2622
|
+
status = str(item.get("status") or "")
|
|
2623
|
+
name = str(item.get("name") or "")
|
|
2624
|
+
item_view_key = str(item.get("view_key") or "").strip()
|
|
2625
|
+
present_in_readback: bool | None
|
|
2626
|
+
if status in {"created", "updated"}:
|
|
2627
|
+
if verified_views_unavailable:
|
|
2628
|
+
present_in_readback = None
|
|
2629
|
+
elif item_view_key:
|
|
2630
|
+
present_in_readback = item_view_key in verified_by_key
|
|
2631
|
+
else:
|
|
2632
|
+
present_in_readback = name in verified_names
|
|
2633
|
+
verification_entry: dict[str, Any] = {
|
|
2634
|
+
"name": name,
|
|
2635
|
+
"view_key": item_view_key or None,
|
|
2636
|
+
"type": item.get("type"),
|
|
2637
|
+
"status": status,
|
|
2638
|
+
"present_in_readback": present_in_readback,
|
|
2639
|
+
}
|
|
2640
|
+
expected_filters = item.get("expected_filters") or []
|
|
2641
|
+
if expected_filters:
|
|
2642
|
+
if verified_views_unavailable or not present_in_readback:
|
|
2643
|
+
verification_entry["filters_verified"] = None
|
|
2644
|
+
verification_entry["filter_readback_pending"] = True
|
|
2645
|
+
filter_readback_pending = True
|
|
2646
|
+
else:
|
|
2647
|
+
verification_key = item_view_key
|
|
2648
|
+
if not verification_key:
|
|
2649
|
+
matched_keys = verified_view_keys_by_name.get(name) or []
|
|
2650
|
+
if len(matched_keys) == 1:
|
|
2651
|
+
verification_key = matched_keys[0]
|
|
2652
|
+
else:
|
|
2653
|
+
verification_entry["filters_verified"] = None
|
|
2654
|
+
verification_entry["filter_readback_pending"] = True
|
|
2655
|
+
verification_entry["readback_ambiguous"] = True
|
|
2656
|
+
verification_entry["matching_view_keys"] = matched_keys
|
|
2657
|
+
filter_readback_pending = True
|
|
2658
|
+
verification_by_view.append(verification_entry)
|
|
2659
|
+
continue
|
|
2660
|
+
try:
|
|
2661
|
+
config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
|
|
2662
|
+
actual_filters = _normalize_view_filter_groups_for_compare((config_response.get("result") or {}).get("viewgraphLimit"))
|
|
2663
|
+
expected_filter_summary = _normalize_view_filter_groups_for_compare(expected_filters)
|
|
2664
|
+
filters_verified = actual_filters == expected_filter_summary
|
|
2665
|
+
verification_entry["filters_verified"] = filters_verified
|
|
2666
|
+
verification_entry["view_key"] = verification_key
|
|
2667
|
+
verification_entry["expected_filters"] = expected_filter_summary
|
|
2668
|
+
verification_entry["actual_filters"] = actual_filters
|
|
2669
|
+
if not filters_verified:
|
|
2670
|
+
filter_mismatches.append(
|
|
2671
|
+
{
|
|
2672
|
+
"name": name,
|
|
2673
|
+
"type": item.get("type"),
|
|
2674
|
+
"expected_filters": expected_filter_summary,
|
|
2675
|
+
"actual_filters": actual_filters,
|
|
2676
|
+
}
|
|
2677
|
+
)
|
|
2678
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2679
|
+
api_error = _coerce_api_error(error)
|
|
2680
|
+
verification_entry["filters_verified"] = None
|
|
2681
|
+
verification_entry["filter_readback_pending"] = True
|
|
2682
|
+
verification_entry["request_id"] = api_error.request_id
|
|
2683
|
+
verification_entry["transport_error"] = {
|
|
2684
|
+
"http_status": api_error.http_status,
|
|
2685
|
+
"backend_code": api_error.backend_code,
|
|
2686
|
+
"category": api_error.category,
|
|
2687
|
+
}
|
|
2688
|
+
filter_readback_pending = True
|
|
2689
|
+
verification_by_view.append(verification_entry)
|
|
2690
|
+
elif status == "removed":
|
|
2691
|
+
verification_by_view.append(
|
|
2692
|
+
{
|
|
2693
|
+
"name": name,
|
|
2694
|
+
"type": item.get("type"),
|
|
2695
|
+
"status": "removed",
|
|
2696
|
+
"present_in_readback": None if verified_views_unavailable else name not in verified_names,
|
|
2697
|
+
}
|
|
2698
|
+
)
|
|
2699
|
+
else:
|
|
2700
|
+
verification_by_view.append(
|
|
2701
|
+
{
|
|
2702
|
+
"name": item.get("name"),
|
|
2703
|
+
"type": item.get("type"),
|
|
2704
|
+
"status": "failed",
|
|
2705
|
+
"present_in_readback": None,
|
|
2706
|
+
"error_code": item.get("error_code"),
|
|
2707
|
+
}
|
|
1354
2708
|
)
|
|
1355
|
-
|
|
1356
|
-
|
|
2709
|
+
verified = (
|
|
2710
|
+
(not verified_views_unavailable)
|
|
2711
|
+
and all(name in verified_names for name in created + updated)
|
|
2712
|
+
and all(name not in verified_names for name in removed)
|
|
2713
|
+
and not filter_readback_pending
|
|
2714
|
+
and not filter_mismatches
|
|
2715
|
+
)
|
|
1357
2716
|
noop = not created and not updated and not removed
|
|
1358
|
-
|
|
2717
|
+
if failed_views:
|
|
2718
|
+
successful_changes = bool(created or updated or removed)
|
|
2719
|
+
first_failure = failed_views[0]
|
|
2720
|
+
response = {
|
|
2721
|
+
"status": "partial_success" if successful_changes else "failed",
|
|
2722
|
+
"error_code": "VIEW_APPLY_PARTIAL" if successful_changes else "VIEW_APPLY_FAILED",
|
|
2723
|
+
"recoverable": True,
|
|
2724
|
+
"message": "applied some view patches; at least one view failed" if successful_changes else "one or more view patches failed",
|
|
2725
|
+
"normalized_args": normalized_args,
|
|
2726
|
+
"missing_fields": [],
|
|
2727
|
+
"allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
2728
|
+
"details": {"per_view_results": view_results, "filter_mismatches": filter_mismatches},
|
|
2729
|
+
"request_id": first_failure.get("request_id"),
|
|
2730
|
+
"suggested_next_call": {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2731
|
+
"backend_code": first_failure.get("backend_code"),
|
|
2732
|
+
"http_status": first_failure.get("http_status"),
|
|
2733
|
+
"noop": noop,
|
|
2734
|
+
"verification": {
|
|
2735
|
+
"views_verified": verified,
|
|
2736
|
+
"views_read_unavailable": verified_views_unavailable,
|
|
2737
|
+
"by_view": verification_by_view,
|
|
2738
|
+
},
|
|
2739
|
+
"app_key": app_key,
|
|
2740
|
+
"views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
|
|
2741
|
+
"verified": verified,
|
|
2742
|
+
}
|
|
2743
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
2744
|
+
response = {
|
|
1359
2745
|
"status": "success" if verified else "partial_success",
|
|
1360
|
-
"error_code": None,
|
|
1361
|
-
"recoverable":
|
|
1362
|
-
"message":
|
|
2746
|
+
"error_code": None if verified else ("VIEW_FILTER_READBACK_MISMATCH" if filter_mismatches else "VIEWS_READBACK_PENDING"),
|
|
2747
|
+
"recoverable": not verified,
|
|
2748
|
+
"message": (
|
|
2749
|
+
"applied view patch"
|
|
2750
|
+
if verified
|
|
2751
|
+
else "applied view patch; filters did not fully verify"
|
|
2752
|
+
if filter_mismatches
|
|
2753
|
+
else "applied view patch; views readback pending"
|
|
2754
|
+
),
|
|
1363
2755
|
"normalized_args": normalized_args,
|
|
1364
2756
|
"missing_fields": [],
|
|
1365
|
-
"allowed_values": {"view_types": [
|
|
1366
|
-
"details": {},
|
|
2757
|
+
"allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
2758
|
+
"details": {"filter_mismatches": filter_mismatches} if filter_mismatches else {},
|
|
1367
2759
|
"request_id": None,
|
|
1368
|
-
"suggested_next_call": None,
|
|
2760
|
+
"suggested_next_call": None if verified else {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1369
2761
|
"noop": noop,
|
|
1370
|
-
"verification": {
|
|
2762
|
+
"verification": {
|
|
2763
|
+
"views_verified": verified,
|
|
2764
|
+
"views_read_unavailable": verified_views_unavailable,
|
|
2765
|
+
"filter_readback_pending": filter_readback_pending,
|
|
2766
|
+
"by_view": verification_by_view,
|
|
2767
|
+
},
|
|
1371
2768
|
"app_key": app_key,
|
|
1372
|
-
"views_diff": {"created": created, "updated": updated, "removed": removed},
|
|
2769
|
+
"views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
|
|
1373
2770
|
"verified": verified,
|
|
1374
2771
|
}
|
|
2772
|
+
return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
|
|
1375
2773
|
|
|
1376
2774
|
def app_publish_verify(
|
|
1377
2775
|
self,
|
|
@@ -1381,12 +2779,33 @@ class AiBuilderFacade:
|
|
|
1381
2779
|
expected_package_tag_id: int | None = None,
|
|
1382
2780
|
) -> JSONObject:
|
|
1383
2781
|
normalized_args = {"app_key": app_key, "expected_package_tag_id": expected_package_tag_id}
|
|
1384
|
-
|
|
2782
|
+
try:
|
|
2783
|
+
base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
|
|
2784
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2785
|
+
api_error = _coerce_api_error(error)
|
|
2786
|
+
return _failed_from_api_error(
|
|
2787
|
+
"APP_READ_FAILED",
|
|
2788
|
+
api_error,
|
|
2789
|
+
normalized_args=normalized_args,
|
|
2790
|
+
details={"app_key": app_key},
|
|
2791
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2792
|
+
)
|
|
1385
2793
|
tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
|
|
1386
2794
|
already_published = bool(base_before.get("appPublishStatus") in {1, 2})
|
|
1387
2795
|
package_already_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_before
|
|
1388
|
-
|
|
1389
|
-
|
|
2796
|
+
try:
|
|
2797
|
+
views_before, views_before_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
2798
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2799
|
+
api_error = _coerce_api_error(error)
|
|
2800
|
+
return _failed_from_api_error(
|
|
2801
|
+
"VIEWS_READ_FAILED",
|
|
2802
|
+
api_error,
|
|
2803
|
+
normalized_args=normalized_args,
|
|
2804
|
+
details={"app_key": app_key},
|
|
2805
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2806
|
+
)
|
|
2807
|
+
views_before = views_before or []
|
|
2808
|
+
if already_published and package_already_attached is not False and isinstance(views_before, list) and not views_before_unavailable:
|
|
1390
2809
|
return {
|
|
1391
2810
|
"status": "success",
|
|
1392
2811
|
"error_code": None,
|
|
@@ -1410,28 +2829,49 @@ class AiBuilderFacade:
|
|
|
1410
2829
|
version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
|
|
1411
2830
|
edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
|
|
1412
2831
|
try:
|
|
1413
|
-
self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
1414
|
-
self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
2832
|
+
self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
2833
|
+
self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
2834
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2835
|
+
api_error = _coerce_api_error(error)
|
|
2836
|
+
return _failed_from_api_error(
|
|
2837
|
+
"PUBLISH_FAILED",
|
|
2838
|
+
api_error,
|
|
2839
|
+
normalized_args=normalized_args,
|
|
2840
|
+
details={"app_key": app_key, "edit_version_no": edit_version_no},
|
|
2841
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2842
|
+
)
|
|
2843
|
+
try:
|
|
2844
|
+
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
|
|
2845
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2846
|
+
api_error = _coerce_api_error(error)
|
|
2847
|
+
return _failed_from_api_error(
|
|
2848
|
+
"APP_READ_FAILED",
|
|
2849
|
+
api_error,
|
|
2850
|
+
normalized_args=normalized_args,
|
|
2851
|
+
details={"app_key": app_key},
|
|
2852
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2853
|
+
)
|
|
2854
|
+
tag_ids_after = _coerce_int_list(base.get("tagIds"))
|
|
2855
|
+
package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
|
|
2856
|
+
try:
|
|
2857
|
+
views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
1415
2858
|
except (QingflowApiError, RuntimeError) as error:
|
|
1416
2859
|
api_error = _coerce_api_error(error)
|
|
1417
2860
|
return _failed_from_api_error(
|
|
1418
|
-
"
|
|
2861
|
+
"VIEWS_READ_FAILED",
|
|
1419
2862
|
api_error,
|
|
1420
2863
|
normalized_args=normalized_args,
|
|
1421
|
-
details={"app_key": app_key
|
|
1422
|
-
suggested_next_call={"tool_name": "
|
|
2864
|
+
details={"app_key": app_key},
|
|
2865
|
+
suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
|
|
1423
2866
|
)
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
|
|
1427
|
-
views = self.views.view_list_flat(profile=profile, app_key=app_key).get("result") or []
|
|
1428
|
-
views_ok = isinstance(views, list)
|
|
2867
|
+
views = views or []
|
|
2868
|
+
views_ok = isinstance(views, list) and not views_unavailable
|
|
1429
2869
|
verified = bool(base.get("appPublishStatus") in {1, 2}) and (package_attached is not False) and views_ok
|
|
1430
2870
|
return {
|
|
1431
2871
|
"status": "success" if verified else "partial_success",
|
|
1432
|
-
"error_code": None,
|
|
1433
|
-
"recoverable":
|
|
1434
|
-
"message": "published and verified app",
|
|
2872
|
+
"error_code": None if not views_unavailable else "VIEWS_READBACK_PENDING",
|
|
2873
|
+
"recoverable": bool(views_unavailable),
|
|
2874
|
+
"message": "published and verified app" if not views_unavailable else "published app; views readback pending",
|
|
1435
2875
|
"normalized_args": normalized_args,
|
|
1436
2876
|
"missing_fields": [],
|
|
1437
2877
|
"allowed_values": {},
|
|
@@ -1444,7 +2884,7 @@ class AiBuilderFacade:
|
|
|
1444
2884
|
"arguments": {"profile": profile, "tag_id": expected_package_tag_id, "app_key": app_key},
|
|
1445
2885
|
},
|
|
1446
2886
|
"noop": False,
|
|
1447
|
-
"verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok},
|
|
2887
|
+
"verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
|
|
1448
2888
|
"app_key": app_key,
|
|
1449
2889
|
"published": bool(base.get("appPublishStatus") in {1, 2}),
|
|
1450
2890
|
"package_attached": package_attached,
|
|
@@ -1453,29 +2893,163 @@ class AiBuilderFacade:
|
|
|
1453
2893
|
"verified": verified,
|
|
1454
2894
|
}
|
|
1455
2895
|
|
|
1456
|
-
def
|
|
2896
|
+
def _publish_current_edit_version(self, *, profile: str, app_key: str) -> JSONObject:
|
|
2897
|
+
normalized_args = {"app_key": app_key}
|
|
2898
|
+
version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
|
|
2899
|
+
edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
|
|
2900
|
+
try:
|
|
2901
|
+
self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
2902
|
+
self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
2903
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2904
|
+
api_error = _coerce_api_error(error)
|
|
2905
|
+
return _failed_from_api_error(
|
|
2906
|
+
"PUBLISH_FAILED",
|
|
2907
|
+
api_error,
|
|
2908
|
+
normalized_args=normalized_args,
|
|
2909
|
+
details={"app_key": app_key, "edit_version_no": edit_version_no},
|
|
2910
|
+
suggested_next_call={"tool_name": "app_publish_verify", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2911
|
+
)
|
|
2912
|
+
return {
|
|
2913
|
+
"status": "success",
|
|
2914
|
+
"error_code": None,
|
|
2915
|
+
"recoverable": False,
|
|
2916
|
+
"message": "published current app draft",
|
|
2917
|
+
"normalized_args": normalized_args,
|
|
2918
|
+
"missing_fields": [],
|
|
2919
|
+
"allowed_values": {},
|
|
2920
|
+
"details": {"app_key": app_key, "edit_version_no": edit_version_no},
|
|
2921
|
+
"request_id": None,
|
|
2922
|
+
"suggested_next_call": None,
|
|
2923
|
+
"noop": False,
|
|
2924
|
+
"verification": {"published": True},
|
|
2925
|
+
"app_key": app_key,
|
|
2926
|
+
"published": True,
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
def _resolve_form_edit_version(self, *, profile: str, app_key: str, current_schema: dict[str, Any]) -> int:
|
|
2930
|
+
try:
|
|
2931
|
+
version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
|
|
2932
|
+
except (QingflowApiError, RuntimeError):
|
|
2933
|
+
version_result = {}
|
|
2934
|
+
return _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or int(current_schema.get("editVersionNo") or 1)
|
|
2935
|
+
|
|
2936
|
+
def _append_publish_result(self, *, profile: str, app_key: str, publish: bool, response: JSONObject) -> JSONObject:
|
|
2937
|
+
response["publish_requested"] = publish
|
|
2938
|
+
if not publish:
|
|
2939
|
+
response["published"] = False
|
|
2940
|
+
return response
|
|
2941
|
+
publish_result = self._publish_current_edit_version(profile=profile, app_key=app_key)
|
|
2942
|
+
response["publish_result"] = publish_result
|
|
2943
|
+
response["published"] = bool(publish_result.get("published"))
|
|
2944
|
+
verification = response.get("verification")
|
|
2945
|
+
if not isinstance(verification, dict):
|
|
2946
|
+
verification = {}
|
|
2947
|
+
response["verification"] = verification
|
|
2948
|
+
verification["published"] = bool(publish_result.get("published"))
|
|
2949
|
+
if publish_result.get("status") == "failed":
|
|
2950
|
+
response["status"] = "partial_success"
|
|
2951
|
+
response["error_code"] = response.get("error_code") or publish_result.get("error_code")
|
|
2952
|
+
response["recoverable"] = True
|
|
2953
|
+
response["message"] = f"{response.get('message') or 'apply succeeded'}; publish failed"
|
|
2954
|
+
if not response.get("suggested_next_call"):
|
|
2955
|
+
response["suggested_next_call"] = publish_result.get("suggested_next_call")
|
|
2956
|
+
return response
|
|
2957
|
+
|
|
2958
|
+
def _load_base_schema_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
|
|
1457
2959
|
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
|
|
1458
|
-
|
|
1459
|
-
profile=profile,
|
|
1460
|
-
app_key=app_key,
|
|
1461
|
-
form_type=1,
|
|
1462
|
-
being_draft=True,
|
|
1463
|
-
being_apply=None,
|
|
1464
|
-
audit_node_id=None,
|
|
1465
|
-
include_raw=True,
|
|
1466
|
-
)
|
|
1467
|
-
views = self.views.view_list_flat(profile=profile, app_key=app_key)
|
|
1468
|
-
workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
|
|
2960
|
+
schema_result, schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
1469
2961
|
base_result = base.get("result") if isinstance(base.get("result"), dict) else {}
|
|
1470
|
-
schema_result = schema.get("result") if isinstance(schema.get("result"), dict) else {}
|
|
1471
2962
|
return {
|
|
1472
2963
|
"base": base_result,
|
|
1473
2964
|
"schema": schema_result,
|
|
1474
2965
|
"parsed": _parse_schema(schema_result),
|
|
1475
|
-
"
|
|
1476
|
-
"workflow": workflow.get("result"),
|
|
2966
|
+
"schema_source": schema_source,
|
|
1477
2967
|
}
|
|
1478
2968
|
|
|
2969
|
+
def _load_views_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
|
|
2970
|
+
try:
|
|
2971
|
+
views = self.views.view_list_flat(profile=profile, app_key=app_key)
|
|
2972
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2973
|
+
api_error = _coerce_api_error(error)
|
|
2974
|
+
if api_error.http_status == 404:
|
|
2975
|
+
try:
|
|
2976
|
+
legacy_views = self.views.view_list(profile=profile, app_key=app_key)
|
|
2977
|
+
except (QingflowApiError, RuntimeError) as legacy_error:
|
|
2978
|
+
legacy_api_error = _coerce_api_error(legacy_error)
|
|
2979
|
+
if tolerate_404 and legacy_api_error.http_status == 404:
|
|
2980
|
+
return [], True
|
|
2981
|
+
raise
|
|
2982
|
+
legacy_result = legacy_views.get("result")
|
|
2983
|
+
if _is_view_collection_shape(legacy_result):
|
|
2984
|
+
return _normalize_view_collection(legacy_result), False
|
|
2985
|
+
if tolerate_404:
|
|
2986
|
+
return [], True
|
|
2987
|
+
raise error
|
|
2988
|
+
raise
|
|
2989
|
+
normalized_views = _normalize_view_collection(views.get("result"))
|
|
2990
|
+
if normalized_views:
|
|
2991
|
+
return normalized_views, False
|
|
2992
|
+
try:
|
|
2993
|
+
legacy_views = self.views.view_list(profile=profile, app_key=app_key)
|
|
2994
|
+
except (QingflowApiError, RuntimeError) as legacy_error:
|
|
2995
|
+
legacy_api_error = _coerce_api_error(legacy_error)
|
|
2996
|
+
if tolerate_404 and legacy_api_error.http_status == 404:
|
|
2997
|
+
return normalized_views, False
|
|
2998
|
+
raise
|
|
2999
|
+
legacy_result = legacy_views.get("result")
|
|
3000
|
+
legacy_normalized = _normalize_view_collection(legacy_result)
|
|
3001
|
+
return legacy_normalized or normalized_views, False
|
|
3002
|
+
|
|
3003
|
+
def _load_workflow_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
|
|
3004
|
+
try:
|
|
3005
|
+
workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
|
|
3006
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3007
|
+
api_error = _coerce_api_error(error)
|
|
3008
|
+
if tolerate_404 and api_error.http_status == 404:
|
|
3009
|
+
return [], True
|
|
3010
|
+
raise
|
|
3011
|
+
return workflow.get("result"), False
|
|
3012
|
+
|
|
3013
|
+
def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
|
|
3014
|
+
state = self._load_base_schema_state(profile=profile, app_key=app_key)
|
|
3015
|
+
views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
3016
|
+
workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
3017
|
+
state["views"] = views
|
|
3018
|
+
state["workflow"] = workflow
|
|
3019
|
+
state["views_unavailable"] = views_unavailable
|
|
3020
|
+
state["workflow_unavailable"] = workflow_unavailable
|
|
3021
|
+
return state
|
|
3022
|
+
|
|
3023
|
+
def _read_schema_with_fallback(self, *, profile: str, app_key: str) -> tuple[dict[str, Any], str]:
|
|
3024
|
+
attempts = (
|
|
3025
|
+
("draft", True),
|
|
3026
|
+
("current", None),
|
|
3027
|
+
("published", False),
|
|
3028
|
+
)
|
|
3029
|
+
last_error: Exception | None = None
|
|
3030
|
+
for label, being_draft in attempts:
|
|
3031
|
+
try:
|
|
3032
|
+
schema = self.apps.app_get_form_schema(
|
|
3033
|
+
profile=profile,
|
|
3034
|
+
app_key=app_key,
|
|
3035
|
+
form_type=1,
|
|
3036
|
+
being_draft=being_draft,
|
|
3037
|
+
being_apply=None,
|
|
3038
|
+
audit_node_id=None,
|
|
3039
|
+
include_raw=True,
|
|
3040
|
+
)
|
|
3041
|
+
result = schema.get("result")
|
|
3042
|
+
return (result if isinstance(result, dict) else {}), label
|
|
3043
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3044
|
+
api_error = _coerce_api_error(error)
|
|
3045
|
+
last_error = error
|
|
3046
|
+
if api_error.http_status == 404:
|
|
3047
|
+
continue
|
|
3048
|
+
raise
|
|
3049
|
+
if last_error is not None:
|
|
3050
|
+
raise last_error
|
|
3051
|
+
return {}, "unknown"
|
|
3052
|
+
|
|
1479
3053
|
def _preview_target_app(
|
|
1480
3054
|
self,
|
|
1481
3055
|
*,
|
|
@@ -1563,7 +3137,28 @@ class AiBuilderFacade:
|
|
|
1563
3137
|
new_app_key = str(result.get("appKey") or (result.get("appKeys")[0] if isinstance(result.get("appKeys"), list) and result.get("appKeys") else ""))
|
|
1564
3138
|
if not new_app_key:
|
|
1565
3139
|
return _failed("APP_CREATE_FAILED", "failed to create app shell", details={"result": result}, suggested_next_call=None)
|
|
1566
|
-
|
|
3140
|
+
try:
|
|
3141
|
+
base = self.apps.app_get_base(profile=profile, app_key=new_app_key, include_raw=True).get("result") or {}
|
|
3142
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3143
|
+
api_error = _coerce_api_error(error)
|
|
3144
|
+
if api_error.http_status != 404:
|
|
3145
|
+
return _failed_from_api_error(
|
|
3146
|
+
"APP_CREATE_READBACK_FAILED",
|
|
3147
|
+
api_error,
|
|
3148
|
+
details={"app_key": new_app_key, "app_name": app_name, "package_tag_id": package_tag_id},
|
|
3149
|
+
suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": new_app_key}},
|
|
3150
|
+
)
|
|
3151
|
+
return {
|
|
3152
|
+
"status": "success",
|
|
3153
|
+
"error_code": None,
|
|
3154
|
+
"recoverable": False,
|
|
3155
|
+
"message": "created app; base readback pending",
|
|
3156
|
+
"suggested_next_call": None,
|
|
3157
|
+
"app_key": new_app_key,
|
|
3158
|
+
"app_name": app_name or "未命名应用",
|
|
3159
|
+
"tag_ids": [],
|
|
3160
|
+
"created": True,
|
|
3161
|
+
}
|
|
1567
3162
|
return {
|
|
1568
3163
|
"status": "success",
|
|
1569
3164
|
"error_code": None,
|
|
@@ -1594,6 +3189,33 @@ class AiBuilderFacade:
|
|
|
1594
3189
|
payload.setdefault("ws_id", session_profile.selected_ws_id)
|
|
1595
3190
|
return payload
|
|
1596
3191
|
|
|
3192
|
+
def _resolve_current_user_identity(self, *, profile: str) -> JSONObject:
|
|
3193
|
+
session_profile = self.apps.sessions.get_profile(profile)
|
|
3194
|
+
backend_session = self.apps.sessions.get_backend_session(profile)
|
|
3195
|
+
current_email = str((session_profile.email if session_profile else None) or "").strip()
|
|
3196
|
+
current_name = str((session_profile.nick_name if session_profile else None) or "").strip()
|
|
3197
|
+
if current_email or current_name or session_profile is None or backend_session is None:
|
|
3198
|
+
return {"email": current_email or None, "nick_name": current_name or None}
|
|
3199
|
+
try:
|
|
3200
|
+
user_info = self.apps.backend.request(
|
|
3201
|
+
"GET",
|
|
3202
|
+
BackendRequestContext(
|
|
3203
|
+
base_url=backend_session.base_url,
|
|
3204
|
+
token=backend_session.token,
|
|
3205
|
+
ws_id=session_profile.selected_ws_id,
|
|
3206
|
+
qf_version=backend_session.qf_version,
|
|
3207
|
+
qf_version_source=backend_session.qf_version_source,
|
|
3208
|
+
),
|
|
3209
|
+
"/user",
|
|
3210
|
+
)
|
|
3211
|
+
except (QingflowApiError, RuntimeError):
|
|
3212
|
+
return {"email": current_email or None, "nick_name": current_name or None}
|
|
3213
|
+
if not isinstance(user_info, dict):
|
|
3214
|
+
return {"email": current_email or None, "nick_name": current_name or None}
|
|
3215
|
+
resolved_email = str(user_info.get("email") or "").strip() or None
|
|
3216
|
+
resolved_name = str(user_info.get("nickName") or user_info.get("displayName") or user_info.get("name") or "").strip() or None
|
|
3217
|
+
return {"email": resolved_email, "nick_name": resolved_name}
|
|
3218
|
+
|
|
1597
3219
|
def _attach_app_to_package(self, *, profile: str, app_key: str, app_title: str, package_tag_id: int) -> None:
|
|
1598
3220
|
detail = self.packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=True)
|
|
1599
3221
|
result = detail.get("result") if isinstance(detail.get("result"), dict) else {}
|
|
@@ -1658,18 +3280,49 @@ def _failed_from_api_error(
|
|
|
1658
3280
|
suggested_next_call: JSONObject | None = None,
|
|
1659
3281
|
recoverable: bool = True,
|
|
1660
3282
|
) -> JSONObject:
|
|
3283
|
+
effective_error_code = "APP_EDIT_LOCKED" if error.backend_code == 40074 else error_code
|
|
3284
|
+
public_message = _public_error_message(effective_error_code, error)
|
|
3285
|
+
public_http_status = None if error.http_status == 404 else error.http_status
|
|
3286
|
+
merged_details = dict(details or {})
|
|
3287
|
+
if error.backend_code == 40074:
|
|
3288
|
+
owner = _extract_edit_lock_owner(error.message)
|
|
3289
|
+
merged_details.setdefault("lock_owner_name", owner.get("lock_owner_name"))
|
|
3290
|
+
merged_details.setdefault("lock_owner_email", owner.get("lock_owner_email"))
|
|
3291
|
+
app_key = None
|
|
3292
|
+
if isinstance(normalized_args, dict):
|
|
3293
|
+
app_key = normalized_args.get("app_key")
|
|
3294
|
+
if not app_key and isinstance(details, dict):
|
|
3295
|
+
app_key = details.get("app_key")
|
|
3296
|
+
if isinstance(app_key, str) and app_key.strip():
|
|
3297
|
+
suggested_next_call = {
|
|
3298
|
+
"tool_name": "app_release_edit_lock_if_mine",
|
|
3299
|
+
"arguments": {
|
|
3300
|
+
"app_key": app_key,
|
|
3301
|
+
"lock_owner_name": owner.get("lock_owner_name") or "",
|
|
3302
|
+
"lock_owner_email": owner.get("lock_owner_email") or "",
|
|
3303
|
+
},
|
|
3304
|
+
}
|
|
3305
|
+
if error.http_status is not None or error.backend_code is not None:
|
|
3306
|
+
merged_details.setdefault(
|
|
3307
|
+
"transport_error",
|
|
3308
|
+
{
|
|
3309
|
+
"http_status": error.http_status,
|
|
3310
|
+
"backend_code": error.backend_code,
|
|
3311
|
+
"category": error.category,
|
|
3312
|
+
},
|
|
3313
|
+
)
|
|
1661
3314
|
return _failed(
|
|
1662
|
-
|
|
1663
|
-
|
|
3315
|
+
effective_error_code,
|
|
3316
|
+
public_message,
|
|
1664
3317
|
recoverable=recoverable,
|
|
1665
3318
|
normalized_args=normalized_args,
|
|
1666
3319
|
missing_fields=missing_fields,
|
|
1667
3320
|
allowed_values=allowed_values,
|
|
1668
|
-
details=
|
|
3321
|
+
details=merged_details,
|
|
1669
3322
|
suggested_next_call=suggested_next_call,
|
|
1670
3323
|
request_id=error.request_id,
|
|
1671
3324
|
backend_code=error.backend_code,
|
|
1672
|
-
http_status=
|
|
3325
|
+
http_status=public_http_status,
|
|
1673
3326
|
)
|
|
1674
3327
|
|
|
1675
3328
|
|
|
@@ -1708,6 +3361,52 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
|
|
|
1708
3361
|
return QingflowApiError(category="runtime", message=str(error))
|
|
1709
3362
|
|
|
1710
3363
|
|
|
3364
|
+
def _public_error_message(error_code: str, error: QingflowApiError) -> str:
|
|
3365
|
+
if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
|
|
3366
|
+
owner = _extract_edit_lock_owner(error.message)
|
|
3367
|
+
owner_label = owner.get("lock_owner_email") or owner.get("lock_owner_name")
|
|
3368
|
+
if owner_label:
|
|
3369
|
+
return f"app is currently locked by active editor {owner_label}"
|
|
3370
|
+
return "app is currently locked by another active editor session"
|
|
3371
|
+
if error.http_status != 404:
|
|
3372
|
+
return error.message
|
|
3373
|
+
mapping = {
|
|
3374
|
+
"APP_READ_FAILED": "app base or schema is unavailable in the current route",
|
|
3375
|
+
"FIELDS_READ_FAILED": "app fields are unavailable in the current route",
|
|
3376
|
+
"LAYOUT_READ_FAILED": "layout resource is unavailable for this app in the current route",
|
|
3377
|
+
"VIEWS_READ_FAILED": "views resource is unavailable for this app in the current route",
|
|
3378
|
+
"FLOW_READ_FAILED": "workflow resource is unavailable for this app in the current route",
|
|
3379
|
+
"SCHEMA_READBACK_FAILED": "schema was written but schema readback is unavailable in the current route",
|
|
3380
|
+
"CREATE_APP_ROUTE_NOT_FOUND": "create app route is unavailable in the current workspace route",
|
|
3381
|
+
"APP_CREATE_READBACK_FAILED": "app was created but base readback is unavailable in the current route",
|
|
3382
|
+
"PACKAGE_ATTACH_FAILED": "package attachment could not be verified in the current route",
|
|
3383
|
+
"PUBLISH_FAILED": "publish route is unavailable in the current route",
|
|
3384
|
+
"VIEW_APPLY_FAILED": "view resource rejected the operation or is unavailable in the current route",
|
|
3385
|
+
"LAYOUT_APPLY_FAILED": "layout resource rejected the operation or is unavailable in the current route",
|
|
3386
|
+
"SCHEMA_APPLY_FAILED": "schema resource rejected the operation or is unavailable in the current route",
|
|
3387
|
+
"EDIT_LOCK_RELEASE_FAILED": "edit lock release route is unavailable in the current route",
|
|
3388
|
+
}
|
|
3389
|
+
return mapping.get(error_code, "requested builder resource is unavailable in the current route")
|
|
3390
|
+
|
|
3391
|
+
|
|
3392
|
+
def _extract_edit_lock_owner(message: str) -> JSONObject:
|
|
3393
|
+
text = str(message or "").strip()
|
|
3394
|
+
if not text:
|
|
3395
|
+
return {"lock_owner_name": None, "lock_owner_email": None}
|
|
3396
|
+
patterns = [
|
|
3397
|
+
r"应用已被\s*(?P<name>[^((]+?)\s*[((](?P<email>[^))]+)[))]\s*编辑",
|
|
3398
|
+
r"edited by\s*(?P<name>[^<(]+?)\s*<(?P<email>[^>]+)>",
|
|
3399
|
+
]
|
|
3400
|
+
for pattern in patterns:
|
|
3401
|
+
match = re.search(pattern, text)
|
|
3402
|
+
if match:
|
|
3403
|
+
return {
|
|
3404
|
+
"lock_owner_name": match.groupdict().get("name", "").strip() or None,
|
|
3405
|
+
"lock_owner_email": match.groupdict().get("email", "").strip() or None,
|
|
3406
|
+
}
|
|
3407
|
+
return {"lock_owner_name": None, "lock_owner_email": None}
|
|
3408
|
+
|
|
3409
|
+
|
|
1711
3410
|
def _coerce_positive_int(value: Any) -> int | None:
|
|
1712
3411
|
if isinstance(value, bool) or value is None:
|
|
1713
3412
|
return None
|
|
@@ -1735,6 +3434,51 @@ def _coerce_int_list(values: Any) -> list[int]:
|
|
|
1735
3434
|
return result
|
|
1736
3435
|
|
|
1737
3436
|
|
|
3437
|
+
def _normalize_view_collection(values: Any) -> list[dict[str, Any]]:
|
|
3438
|
+
if isinstance(values, list):
|
|
3439
|
+
normalized: list[dict[str, Any]] = []
|
|
3440
|
+
for item in values:
|
|
3441
|
+
if not isinstance(item, dict):
|
|
3442
|
+
continue
|
|
3443
|
+
nested_view_list = item.get("viewList")
|
|
3444
|
+
if isinstance(nested_view_list, list):
|
|
3445
|
+
normalized.extend(view for view in nested_view_list if isinstance(view, dict))
|
|
3446
|
+
continue
|
|
3447
|
+
normalized.append(item)
|
|
3448
|
+
return normalized
|
|
3449
|
+
if isinstance(values, dict):
|
|
3450
|
+
for key in ("list", "viewList", "views", "result"):
|
|
3451
|
+
candidate = values.get(key)
|
|
3452
|
+
if isinstance(candidate, list):
|
|
3453
|
+
return _normalize_view_collection(candidate)
|
|
3454
|
+
return []
|
|
3455
|
+
|
|
3456
|
+
|
|
3457
|
+
def _is_view_collection_shape(values: Any) -> bool:
|
|
3458
|
+
if isinstance(values, list):
|
|
3459
|
+
return True
|
|
3460
|
+
if isinstance(values, dict):
|
|
3461
|
+
return any(isinstance(values.get(key), list) for key in ("list", "viewList", "views", "result"))
|
|
3462
|
+
return False
|
|
3463
|
+
|
|
3464
|
+
|
|
3465
|
+
def _extract_view_name(view: dict[str, Any]) -> str:
|
|
3466
|
+
return str(view.get("viewgraphName") or view.get("viewName") or view.get("title") or "").strip()
|
|
3467
|
+
|
|
3468
|
+
|
|
3469
|
+
def _extract_view_key(view: dict[str, Any]) -> str:
|
|
3470
|
+
return str(view.get("viewgraphKey") or view.get("viewKey") or "").strip()
|
|
3471
|
+
|
|
3472
|
+
|
|
3473
|
+
def _empty_schema_result(title: str) -> dict[str, Any]:
|
|
3474
|
+
return {
|
|
3475
|
+
"formTitle": title,
|
|
3476
|
+
"editVersionNo": 1,
|
|
3477
|
+
"formQues": [],
|
|
3478
|
+
"questionRelations": [],
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
|
|
1738
3482
|
def _slugify(text: str, *, default: str) -> str:
|
|
1739
3483
|
normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(text or ""))
|
|
1740
3484
|
collapsed = "_".join(part for part in normalized.split("_") if part)
|
|
@@ -1753,6 +3497,7 @@ def _infer_field_type(question: dict[str, Any]) -> str:
|
|
|
1753
3497
|
def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None) -> dict[str, Any]:
|
|
1754
3498
|
name = str(question.get("queTitle") or "").strip()
|
|
1755
3499
|
que_id = _coerce_positive_int(question.get("queId"))
|
|
3500
|
+
que_type = _coerce_positive_int(question.get("queType"))
|
|
1756
3501
|
field_type = _infer_field_type(question)
|
|
1757
3502
|
field_id = field_id_hint or f"field_{que_id or _slugify(name, default='x')}"
|
|
1758
3503
|
field: dict[str, Any] = {
|
|
@@ -1762,14 +3507,30 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
|
|
|
1762
3507
|
"required": bool(question.get("required")),
|
|
1763
3508
|
"description": question.get("queHint") or None,
|
|
1764
3509
|
"options": [],
|
|
3510
|
+
"option_details": [],
|
|
1765
3511
|
"target_app_key": None,
|
|
1766
3512
|
"subfields": [],
|
|
1767
3513
|
"que_id": que_id,
|
|
3514
|
+
"que_type": que_type,
|
|
1768
3515
|
}
|
|
1769
3516
|
if field_type in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
|
|
1770
3517
|
options = question.get("options")
|
|
1771
3518
|
if isinstance(options, list):
|
|
1772
|
-
|
|
3519
|
+
option_values: list[str] = []
|
|
3520
|
+
option_details: list[dict[str, Any]] = []
|
|
3521
|
+
for item in options:
|
|
3522
|
+
if not isinstance(item, dict) or not item.get("optValue"):
|
|
3523
|
+
continue
|
|
3524
|
+
option_value = str(item.get("optValue") or "")
|
|
3525
|
+
option_values.append(option_value)
|
|
3526
|
+
option_details.append(
|
|
3527
|
+
{
|
|
3528
|
+
"id": item.get("optId"),
|
|
3529
|
+
"value": option_value,
|
|
3530
|
+
}
|
|
3531
|
+
)
|
|
3532
|
+
field["options"] = option_values
|
|
3533
|
+
field["option_details"] = option_details
|
|
1773
3534
|
if field_type == FieldType.relation:
|
|
1774
3535
|
reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
|
|
1775
3536
|
field["target_app_key"] = reference.get("referAppKey")
|
|
@@ -1827,6 +3588,79 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
1827
3588
|
return {"fields": fields, "layout": {"root_rows": root_rows, "sections": sections}}
|
|
1828
3589
|
|
|
1829
3590
|
|
|
3591
|
+
def _resolve_layout_sections_to_names(
|
|
3592
|
+
requested_sections: list[dict[str, Any]],
|
|
3593
|
+
fields: list[dict[str, Any]],
|
|
3594
|
+
) -> tuple[list[dict[str, Any]], list[Any]]:
|
|
3595
|
+
by_name = {str(field.get("name") or ""): str(field.get("name") or "") for field in fields if field.get("name")}
|
|
3596
|
+
by_field_id = {
|
|
3597
|
+
str(field.get("field_id") or ""): str(field.get("name") or "")
|
|
3598
|
+
for field in fields
|
|
3599
|
+
if field.get("field_id") and field.get("name")
|
|
3600
|
+
}
|
|
3601
|
+
by_que_id = {
|
|
3602
|
+
int(field.get("que_id")): str(field.get("name") or "")
|
|
3603
|
+
for field in fields
|
|
3604
|
+
if _coerce_positive_int(field.get("que_id")) is not None and field.get("name")
|
|
3605
|
+
}
|
|
3606
|
+
normalized_sections: list[dict[str, Any]] = []
|
|
3607
|
+
missing_selectors: list[Any] = []
|
|
3608
|
+
for section in requested_sections:
|
|
3609
|
+
if not isinstance(section, dict):
|
|
3610
|
+
continue
|
|
3611
|
+
normalized_rows: list[list[str]] = []
|
|
3612
|
+
for row in section.get("rows", []) or []:
|
|
3613
|
+
if not isinstance(row, list):
|
|
3614
|
+
continue
|
|
3615
|
+
normalized_row: list[str] = []
|
|
3616
|
+
for selector in row:
|
|
3617
|
+
resolved_name = _resolve_layout_field_name(selector, by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
|
|
3618
|
+
if resolved_name is None:
|
|
3619
|
+
missing_selectors.append(selector)
|
|
3620
|
+
continue
|
|
3621
|
+
normalized_row.append(resolved_name)
|
|
3622
|
+
if normalized_row:
|
|
3623
|
+
normalized_rows.append(normalized_row)
|
|
3624
|
+
normalized_section = deepcopy(section)
|
|
3625
|
+
normalized_section["rows"] = normalized_rows
|
|
3626
|
+
normalized_sections.append(normalized_section)
|
|
3627
|
+
return normalized_sections, missing_selectors
|
|
3628
|
+
|
|
3629
|
+
|
|
3630
|
+
def _resolve_layout_field_name(
|
|
3631
|
+
selector: Any,
|
|
3632
|
+
*,
|
|
3633
|
+
by_name: dict[str, str],
|
|
3634
|
+
by_field_id: dict[str, str],
|
|
3635
|
+
by_que_id: dict[int, str],
|
|
3636
|
+
) -> str | None:
|
|
3637
|
+
if isinstance(selector, str):
|
|
3638
|
+
stripped = selector.strip()
|
|
3639
|
+
if not stripped:
|
|
3640
|
+
return None
|
|
3641
|
+
if stripped in by_name:
|
|
3642
|
+
return by_name[stripped]
|
|
3643
|
+
if stripped in by_field_id:
|
|
3644
|
+
return by_field_id[stripped]
|
|
3645
|
+
if stripped.isdigit():
|
|
3646
|
+
return by_que_id.get(int(stripped))
|
|
3647
|
+
return None
|
|
3648
|
+
if isinstance(selector, int) and not isinstance(selector, bool):
|
|
3649
|
+
return by_que_id.get(selector)
|
|
3650
|
+
if isinstance(selector, dict):
|
|
3651
|
+
if selector.get("name"):
|
|
3652
|
+
return _resolve_layout_field_name(selector.get("name"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
|
|
3653
|
+
if selector.get("title"):
|
|
3654
|
+
return _resolve_layout_field_name(selector.get("title"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
|
|
3655
|
+
if selector.get("label"):
|
|
3656
|
+
return _resolve_layout_field_name(selector.get("label"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
|
|
3657
|
+
if selector.get("field_id"):
|
|
3658
|
+
return _resolve_layout_field_name(selector.get("field_id"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
|
|
3659
|
+
if selector.get("que_id") is not None:
|
|
3660
|
+
return _resolve_layout_field_name(selector.get("que_id"), by_name=by_name, by_field_id=by_field_id, by_que_id=by_que_id)
|
|
3661
|
+
return None
|
|
3662
|
+
|
|
3663
|
+
|
|
1830
3664
|
def _build_selector_map(fields: list[dict[str, Any]]) -> dict[str, int]:
|
|
1831
3665
|
mapping: dict[str, int] = {}
|
|
1832
3666
|
for index, field in enumerate(fields):
|
|
@@ -2112,11 +3946,147 @@ def _build_flow_preset(preset: FlowPreset) -> tuple[list[dict[str, Any]], list[d
|
|
|
2112
3946
|
return nodes, transitions
|
|
2113
3947
|
|
|
2114
3948
|
|
|
3949
|
+
def _merge_flow_graph(
|
|
3950
|
+
*,
|
|
3951
|
+
base_nodes: list[dict[str, Any]],
|
|
3952
|
+
base_transitions: list[dict[str, Any]],
|
|
3953
|
+
override_nodes: list[dict[str, Any]],
|
|
3954
|
+
override_transitions: list[dict[str, Any]],
|
|
3955
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
3956
|
+
if not override_nodes and not override_transitions:
|
|
3957
|
+
return deepcopy(base_nodes), deepcopy(base_transitions)
|
|
3958
|
+
override_nodes, override_transitions = _align_flow_preset_override_ids(
|
|
3959
|
+
base_nodes=base_nodes,
|
|
3960
|
+
override_nodes=override_nodes,
|
|
3961
|
+
override_transitions=override_transitions,
|
|
3962
|
+
)
|
|
3963
|
+
merged_nodes: list[dict[str, Any]] = []
|
|
3964
|
+
override_map = {
|
|
3965
|
+
str(node.get("id") or ""): deepcopy(node)
|
|
3966
|
+
for node in override_nodes
|
|
3967
|
+
if isinstance(node, dict) and str(node.get("id") or "")
|
|
3968
|
+
}
|
|
3969
|
+
consumed: set[str] = set()
|
|
3970
|
+
for node in base_nodes:
|
|
3971
|
+
node_id = str(node.get("id") or "")
|
|
3972
|
+
if node_id and node_id in override_map:
|
|
3973
|
+
merged = deepcopy(node)
|
|
3974
|
+
merged.update(override_map[node_id])
|
|
3975
|
+
if isinstance(node.get("config"), dict) and isinstance(override_map[node_id].get("config"), dict):
|
|
3976
|
+
merged["config"] = {**deepcopy(node["config"]), **deepcopy(override_map[node_id]["config"])}
|
|
3977
|
+
if isinstance(node.get("assignees"), dict) and isinstance(override_map[node_id].get("assignees"), dict):
|
|
3978
|
+
merged["assignees"] = {**deepcopy(node["assignees"]), **deepcopy(override_map[node_id]["assignees"])}
|
|
3979
|
+
if isinstance(node.get("permissions"), dict) and isinstance(override_map[node_id].get("permissions"), dict):
|
|
3980
|
+
merged["permissions"] = {**deepcopy(node["permissions"]), **deepcopy(override_map[node_id]["permissions"])}
|
|
3981
|
+
merged_nodes.append(merged)
|
|
3982
|
+
consumed.add(node_id)
|
|
3983
|
+
else:
|
|
3984
|
+
merged_nodes.append(deepcopy(node))
|
|
3985
|
+
for node in override_nodes:
|
|
3986
|
+
node_id = str(node.get("id") or "")
|
|
3987
|
+
if node_id and node_id not in consumed:
|
|
3988
|
+
merged_nodes.append(deepcopy(node))
|
|
3989
|
+
merged_transitions = deepcopy(override_transitions) if override_transitions else deepcopy(base_transitions)
|
|
3990
|
+
return merged_nodes, merged_transitions
|
|
3991
|
+
|
|
3992
|
+
|
|
3993
|
+
def _align_flow_preset_override_ids(
|
|
3994
|
+
*,
|
|
3995
|
+
base_nodes: list[dict[str, Any]],
|
|
3996
|
+
override_nodes: list[dict[str, Any]],
|
|
3997
|
+
override_transitions: list[dict[str, Any]],
|
|
3998
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
3999
|
+
"""Map simple preset skeleton overrides back onto canonical preset node ids.
|
|
4000
|
+
|
|
4001
|
+
This keeps preset-based plans stable when the caller customizes the single
|
|
4002
|
+
approval/fill/copy step semantically but forgets to reuse ids such as
|
|
4003
|
+
`approve_1` or `fill_1`.
|
|
4004
|
+
"""
|
|
4005
|
+
patchable_types = {"approve", "fill", "copy"}
|
|
4006
|
+
base_by_type: dict[str, list[str]] = {}
|
|
4007
|
+
override_by_type: dict[str, list[str]] = {}
|
|
4008
|
+
for node in base_nodes:
|
|
4009
|
+
if not isinstance(node, dict):
|
|
4010
|
+
continue
|
|
4011
|
+
node_type = str(node.get("type") or "")
|
|
4012
|
+
node_id = str(node.get("id") or "")
|
|
4013
|
+
if node_type in patchable_types and node_id:
|
|
4014
|
+
base_by_type.setdefault(node_type, []).append(node_id)
|
|
4015
|
+
for node in override_nodes:
|
|
4016
|
+
if not isinstance(node, dict):
|
|
4017
|
+
continue
|
|
4018
|
+
node_type = str(node.get("type") or "")
|
|
4019
|
+
node_id = str(node.get("id") or "")
|
|
4020
|
+
if node_type in patchable_types and node_id:
|
|
4021
|
+
override_by_type.setdefault(node_type, []).append(node_id)
|
|
4022
|
+
replacement_map: dict[str, str] = {}
|
|
4023
|
+
for node_type in patchable_types:
|
|
4024
|
+
base_ids = base_by_type.get(node_type) or []
|
|
4025
|
+
override_ids = override_by_type.get(node_type) or []
|
|
4026
|
+
if len(base_ids) != 1 or len(override_ids) != 1:
|
|
4027
|
+
continue
|
|
4028
|
+
base_id = base_ids[0]
|
|
4029
|
+
override_id = override_ids[0]
|
|
4030
|
+
if override_id == base_id:
|
|
4031
|
+
continue
|
|
4032
|
+
replacement_map[override_id] = base_id
|
|
4033
|
+
if not replacement_map:
|
|
4034
|
+
return deepcopy(override_nodes), deepcopy(override_transitions)
|
|
4035
|
+
aligned_nodes: list[dict[str, Any]] = []
|
|
4036
|
+
for node in override_nodes:
|
|
4037
|
+
if not isinstance(node, dict):
|
|
4038
|
+
continue
|
|
4039
|
+
aligned = deepcopy(node)
|
|
4040
|
+
node_id = str(aligned.get("id") or "")
|
|
4041
|
+
if node_id in replacement_map:
|
|
4042
|
+
aligned["id"] = replacement_map[node_id]
|
|
4043
|
+
aligned_nodes.append(aligned)
|
|
4044
|
+
aligned_transitions: list[dict[str, Any]] = []
|
|
4045
|
+
for transition in override_transitions:
|
|
4046
|
+
if not isinstance(transition, dict):
|
|
4047
|
+
continue
|
|
4048
|
+
aligned = deepcopy(transition)
|
|
4049
|
+
source = str(aligned.get("from") or "")
|
|
4050
|
+
target = str(aligned.get("to") or "")
|
|
4051
|
+
if source in replacement_map:
|
|
4052
|
+
aligned["from"] = replacement_map[source]
|
|
4053
|
+
if target in replacement_map:
|
|
4054
|
+
aligned["to"] = replacement_map[target]
|
|
4055
|
+
aligned_transitions.append(aligned)
|
|
4056
|
+
return aligned_nodes, aligned_transitions
|
|
4057
|
+
|
|
4058
|
+
|
|
4059
|
+
def _extract_directory_items(listed: JSONObject) -> list[dict[str, Any]]:
|
|
4060
|
+
if isinstance(listed.get("items"), list):
|
|
4061
|
+
return [item for item in listed["items"] if isinstance(item, dict)]
|
|
4062
|
+
result = listed.get("result")
|
|
4063
|
+
if isinstance(result, dict) and isinstance(result.get("result"), list):
|
|
4064
|
+
return [item for item in result["result"] if isinstance(item, dict)]
|
|
4065
|
+
if isinstance(result, list):
|
|
4066
|
+
return [item for item in result if isinstance(item, dict)]
|
|
4067
|
+
return []
|
|
4068
|
+
|
|
4069
|
+
|
|
2115
4070
|
def _build_views_preset(preset: ViewsPreset, field_names: list[str]) -> list[dict[str, Any]]:
|
|
2116
4071
|
ordered = [name for name in field_names if name]
|
|
2117
4072
|
if preset == ViewsPreset.status_board:
|
|
2118
4073
|
group_by = next((name for name in ordered if "状态" in name), ordered[0] if ordered else "")
|
|
2119
4074
|
return [{"name": "按状态看板", "type": "board", "group_by": group_by, "columns": ordered[:3] or ([group_by] if group_by else [])}]
|
|
4075
|
+
if preset == ViewsPreset.default_gantt:
|
|
4076
|
+
title_like = next((name for name in ordered if "名称" in name or "标题" in name), ordered[0] if ordered else "")
|
|
4077
|
+
start_like = next((name for name in ordered if "开始" in name or "起始" in name), "")
|
|
4078
|
+
end_like = next((name for name in ordered if "结束" in name or "截止" in name or "完成" in name), "")
|
|
4079
|
+
columns = [name for name in (title_like, start_like, end_like) if name]
|
|
4080
|
+
return [
|
|
4081
|
+
{
|
|
4082
|
+
"name": "项目甘特图",
|
|
4083
|
+
"type": "gantt",
|
|
4084
|
+
"columns": columns,
|
|
4085
|
+
"start_field": start_like or None,
|
|
4086
|
+
"end_field": end_like or None,
|
|
4087
|
+
"title_field": title_like or None,
|
|
4088
|
+
}
|
|
4089
|
+
]
|
|
2120
4090
|
return [{"name": "全部数据", "type": "table", "columns": ordered[: min(5, len(ordered))]}]
|
|
2121
4091
|
|
|
2122
4092
|
|
|
@@ -2263,13 +4233,20 @@ def _summarize_views(result: Any) -> list[dict[str, Any]]:
|
|
|
2263
4233
|
for view in result:
|
|
2264
4234
|
if not isinstance(view, dict):
|
|
2265
4235
|
continue
|
|
4236
|
+
name = view.get("viewgraphName") or view.get("viewName") or view.get("title")
|
|
4237
|
+
view_key = view.get("viewgraphKey") or view.get("viewKey")
|
|
4238
|
+
view_type = _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))
|
|
4239
|
+
columns = view.get("columnNames") or view.get("columns") or []
|
|
4240
|
+
group_by = view.get("groupBy") or view.get("group_by")
|
|
4241
|
+
if not any((name, view_key, view_type, columns, group_by)):
|
|
4242
|
+
continue
|
|
2266
4243
|
items.append(
|
|
2267
4244
|
{
|
|
2268
|
-
"name":
|
|
2269
|
-
"view_key":
|
|
2270
|
-
"type":
|
|
2271
|
-
"
|
|
2272
|
-
"group_by":
|
|
4245
|
+
"name": name,
|
|
4246
|
+
"view_key": view_key,
|
|
4247
|
+
"type": view_type,
|
|
4248
|
+
"columns": columns,
|
|
4249
|
+
"group_by": group_by,
|
|
2273
4250
|
}
|
|
2274
4251
|
)
|
|
2275
4252
|
return items
|
|
@@ -2376,8 +4353,16 @@ def _build_public_workflow_spec(*, nodes: list[dict[str, Any]], transitions: lis
|
|
|
2376
4353
|
if node_map[parent_id].get("type") == "branch":
|
|
2377
4354
|
payload["branch_parent_id"] = parent_id
|
|
2378
4355
|
payload["branch_index"] = outbound[parent_id].index(node_id) + 1
|
|
2379
|
-
if isinstance(node.get("config"), dict)
|
|
2380
|
-
|
|
4356
|
+
config_payload = deepcopy(node.get("config") or {}) if isinstance(node.get("config"), dict) else {}
|
|
4357
|
+
permissions = node.get("permissions") or {}
|
|
4358
|
+
editable_que_ids = permissions.get("editable_que_ids") or []
|
|
4359
|
+
if editable_que_ids:
|
|
4360
|
+
config_payload["editableQueIds"] = editable_que_ids
|
|
4361
|
+
if config_payload:
|
|
4362
|
+
payload["config"] = config_payload
|
|
4363
|
+
assignees = node.get("assignees") or {}
|
|
4364
|
+
if assignees:
|
|
4365
|
+
payload["assignees"] = deepcopy(assignees)
|
|
2381
4366
|
internal_nodes.append(payload)
|
|
2382
4367
|
return {
|
|
2383
4368
|
"status": "success",
|
|
@@ -2385,6 +4370,91 @@ def _build_public_workflow_spec(*, nodes: list[dict[str, Any]], transitions: lis
|
|
|
2385
4370
|
}
|
|
2386
4371
|
|
|
2387
4372
|
|
|
4373
|
+
def _build_flow_condition_matrix(
|
|
4374
|
+
*,
|
|
4375
|
+
current_fields_by_name: dict[str, dict[str, Any]],
|
|
4376
|
+
node: dict[str, Any],
|
|
4377
|
+
) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
|
|
4378
|
+
if str(node.get("type") or "") != "condition":
|
|
4379
|
+
return [], []
|
|
4380
|
+
raw_groups = node.get("condition_groups") or []
|
|
4381
|
+
if not isinstance(raw_groups, list):
|
|
4382
|
+
return [], []
|
|
4383
|
+
groups: list[list[dict[str, Any]]] = []
|
|
4384
|
+
issues: list[dict[str, Any]] = []
|
|
4385
|
+
for raw_group in raw_groups:
|
|
4386
|
+
if not isinstance(raw_group, list):
|
|
4387
|
+
continue
|
|
4388
|
+
translated_group: list[dict[str, Any]] = []
|
|
4389
|
+
for raw_rule in raw_group:
|
|
4390
|
+
if not isinstance(raw_rule, dict):
|
|
4391
|
+
continue
|
|
4392
|
+
field_name = str(raw_rule.get("field_name") or "").strip()
|
|
4393
|
+
field = current_fields_by_name.get(field_name)
|
|
4394
|
+
if field is None:
|
|
4395
|
+
issues.append(
|
|
4396
|
+
{
|
|
4397
|
+
"kind": "condition_fields",
|
|
4398
|
+
"error_code": "UNKNOWN_FLOW_FIELD",
|
|
4399
|
+
"missing_fields": [field_name] if field_name else [],
|
|
4400
|
+
}
|
|
4401
|
+
)
|
|
4402
|
+
continue
|
|
4403
|
+
translated_group.append(_translate_flow_condition_rule(field=field, rule=raw_rule))
|
|
4404
|
+
if translated_group:
|
|
4405
|
+
groups.append(translated_group)
|
|
4406
|
+
return groups, issues
|
|
4407
|
+
|
|
4408
|
+
|
|
4409
|
+
def _translate_flow_condition_rule(*, field: dict[str, Any], rule: dict[str, Any]) -> dict[str, Any]:
|
|
4410
|
+
operator = str(rule.get("operator") or "").strip().lower()
|
|
4411
|
+
values = list(rule.get("values") or [])
|
|
4412
|
+
field_type = str(field.get("type") or FieldType.text.value)
|
|
4413
|
+
que_id = _coerce_positive_int(field.get("que_id")) or 0
|
|
4414
|
+
que_type = _coerce_positive_int(field.get("que_type")) or FIELD_TYPE_TO_QUESTION_TYPE.get(field_type, 2)
|
|
4415
|
+
base: dict[str, Any] = {
|
|
4416
|
+
"queId": que_id,
|
|
4417
|
+
"queTitle": str(field.get("name") or ""),
|
|
4418
|
+
"queType": que_type,
|
|
4419
|
+
"matchType": MATCH_TYPE_ACCURACY,
|
|
4420
|
+
}
|
|
4421
|
+
if operator == "eq":
|
|
4422
|
+
base["judgeType"] = JUDGE_EQUAL
|
|
4423
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
4424
|
+
elif operator == "neq":
|
|
4425
|
+
base["judgeType"] = JUDGE_UNEQUAL
|
|
4426
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
4427
|
+
elif operator == "in":
|
|
4428
|
+
base["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
|
|
4429
|
+
base["judgeValues"] = [_stringify_condition_value(value) for value in values]
|
|
4430
|
+
elif operator == "contains":
|
|
4431
|
+
base["judgeType"] = JUDGE_FUZZY_MATCH
|
|
4432
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
4433
|
+
elif operator == "gte":
|
|
4434
|
+
base["judgeType"] = JUDGE_GREATER_OR_EQUAL
|
|
4435
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
4436
|
+
elif operator == "lte":
|
|
4437
|
+
base["judgeType"] = JUDGE_LESS_OR_EQUAL
|
|
4438
|
+
base["judgeValues"] = [_stringify_condition_value(values[0])]
|
|
4439
|
+
elif operator == "is_empty":
|
|
4440
|
+
base["judgeType"] = JUDGE_EQUAL
|
|
4441
|
+
base["judgeValues"] = []
|
|
4442
|
+
elif operator == "not_empty":
|
|
4443
|
+
base["judgeType"] = JUDGE_UNEQUAL
|
|
4444
|
+
base["judgeValues"] = []
|
|
4445
|
+
return base
|
|
4446
|
+
|
|
4447
|
+
|
|
4448
|
+
def _stringify_condition_value(value: Any) -> str:
|
|
4449
|
+
if isinstance(value, bool):
|
|
4450
|
+
return "true" if value else "false"
|
|
4451
|
+
if value is None:
|
|
4452
|
+
return ""
|
|
4453
|
+
if isinstance(value, (dict, list)):
|
|
4454
|
+
return json.dumps(value, ensure_ascii=False)
|
|
4455
|
+
return str(value)
|
|
4456
|
+
|
|
4457
|
+
|
|
2388
4458
|
def _build_view_create_payload(
|
|
2389
4459
|
*,
|
|
2390
4460
|
app_key: str,
|
|
@@ -2392,15 +4462,20 @@ def _build_view_create_payload(
|
|
|
2392
4462
|
schema: dict[str, Any],
|
|
2393
4463
|
patch: ViewUpsertPatch,
|
|
2394
4464
|
ordinal: int,
|
|
4465
|
+
view_filters: list[list[dict[str, Any]]],
|
|
2395
4466
|
) -> JSONObject:
|
|
2396
4467
|
entity = _entity_spec_from_app(base_info=base_info, schema=schema, views=None)
|
|
2397
|
-
|
|
4468
|
+
parsed_schema = _parse_schema(schema)
|
|
4469
|
+
visible_field_names = _resolve_view_visible_field_names(patch)
|
|
4470
|
+
field_ids = [_field_id_for_name(parsed_schema["fields"], name) for name in visible_field_names]
|
|
4471
|
+
gantt_config = _build_public_gantt_payload(parsed_schema["fields"], extract_field_map(schema), patch)
|
|
2398
4472
|
view_spec = ViewSpec(
|
|
2399
4473
|
view_id=_slugify(patch.name, default=f"view_{uuid4().hex[:6]}"),
|
|
2400
4474
|
name=patch.name,
|
|
2401
4475
|
type=patch.type.value,
|
|
2402
4476
|
field_ids=field_ids,
|
|
2403
4477
|
group_by_field_id=_field_id_for_name(_parse_schema(schema)["fields"], patch.group_by) if patch.group_by else None,
|
|
4478
|
+
config=gantt_config,
|
|
2404
4479
|
)
|
|
2405
4480
|
from ..solution.spec_models import EntitySpec
|
|
2406
4481
|
from ..solution.compiler.view_compiler import compile_views
|
|
@@ -2408,7 +4483,7 @@ def _build_view_create_payload(
|
|
|
2408
4483
|
compiled = compile_views(EntitySpec.model_validate({**entity, "views": [view_spec.model_dump(mode="json")]}))[0]
|
|
2409
4484
|
field_map = extract_field_map(schema)
|
|
2410
4485
|
payload = deepcopy(compiled["create_payload"])
|
|
2411
|
-
visible_que_ids = [field_map[field_name] for field_name in
|
|
4486
|
+
visible_que_ids = [field_map[field_name] for field_name in visible_field_names if field_name in field_map]
|
|
2412
4487
|
payload["appKey"] = app_key
|
|
2413
4488
|
payload["ordinal"] = ordinal
|
|
2414
4489
|
payload["viewgraphQueIds"] = visible_que_ids
|
|
@@ -2416,19 +4491,19 @@ def _build_view_create_payload(
|
|
|
2416
4491
|
payload["auth"] = default_member_auth()
|
|
2417
4492
|
payload.setdefault("sortType", "defaultSort")
|
|
2418
4493
|
payload.setdefault("viewgraphSorts", [{"queId": 0, "beingSortAscend": True, "queType": 8}])
|
|
2419
|
-
|
|
2420
|
-
payload.setdefault("beingShowCover", False)
|
|
2421
|
-
payload.setdefault("defaultRowHigh", "compact")
|
|
2422
|
-
payload.setdefault("asosChartVisible", False)
|
|
2423
|
-
payload.setdefault("viewgraphLimitType", 1)
|
|
2424
|
-
payload.setdefault("viewgraphLimit", [])
|
|
2425
|
-
payload.setdefault("buttonConfigDTOList", [])
|
|
2426
|
-
if patch.type.value in {"card", "board"}:
|
|
4494
|
+
if patch.type.value in {"card", "board", "gantt"}:
|
|
2427
4495
|
payload["beingShowTitleQue"] = True
|
|
2428
|
-
payload["titleQue"] = visible_que_ids[0] if visible_que_ids else None
|
|
4496
|
+
payload["titleQue"] = gantt_config.get("titleQueId") or (visible_que_ids[0] if visible_que_ids else None)
|
|
2429
4497
|
if patch.type.value == "board":
|
|
2430
4498
|
payload["groupQueId"] = field_map.get(patch.group_by or "")
|
|
2431
|
-
return
|
|
4499
|
+
return _hydrate_view_backend_payload(
|
|
4500
|
+
payload=payload,
|
|
4501
|
+
view_type=patch.type.value,
|
|
4502
|
+
visible_que_id_values=visible_que_ids,
|
|
4503
|
+
group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
|
|
4504
|
+
view_filters=view_filters,
|
|
4505
|
+
gantt_payload=gantt_config,
|
|
4506
|
+
)
|
|
2432
4507
|
|
|
2433
4508
|
|
|
2434
4509
|
def _build_form_payload_from_existing_schema(
|
|
@@ -2551,6 +4626,10 @@ def _pick_view_template_key(existing_views: list[dict[str, Any]], *, desired_typ
|
|
|
2551
4626
|
|
|
2552
4627
|
def _normalize_view_type_name(value: Any) -> str:
|
|
2553
4628
|
normalized = str(value or "").strip().lower()
|
|
4629
|
+
if not normalized:
|
|
4630
|
+
return ""
|
|
4631
|
+
if "gantt" in normalized:
|
|
4632
|
+
return "gantt"
|
|
2554
4633
|
if "board" in normalized:
|
|
2555
4634
|
return "board"
|
|
2556
4635
|
if "card" in normalized:
|
|
@@ -2565,14 +4644,17 @@ def _build_view_update_payload(
|
|
|
2565
4644
|
source_viewgraph_key: str,
|
|
2566
4645
|
schema: dict[str, Any],
|
|
2567
4646
|
patch: ViewUpsertPatch,
|
|
4647
|
+
view_filters: list[list[dict[str, Any]]],
|
|
2568
4648
|
) -> JSONObject:
|
|
2569
4649
|
config_response = views.view_get_config(profile=profile, viewgraph_key=source_viewgraph_key)
|
|
2570
4650
|
config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
|
|
2571
4651
|
payload = deepcopy(config)
|
|
2572
4652
|
parsed_schema = _parse_schema(schema)
|
|
2573
4653
|
field_map = extract_field_map(schema)
|
|
2574
|
-
|
|
2575
|
-
|
|
4654
|
+
visible_field_names = _resolve_view_visible_field_names(patch)
|
|
4655
|
+
visible_que_ids = [_field_id_for_name(parsed_schema["fields"], name) for name in visible_field_names]
|
|
4656
|
+
visible_que_id_values = [field_map[name] for name in visible_field_names if name in field_map]
|
|
4657
|
+
gantt_payload = _build_public_gantt_payload(parsed_schema["fields"], field_map, patch)
|
|
2576
4658
|
|
|
2577
4659
|
for key in (
|
|
2578
4660
|
"appKey",
|
|
@@ -2600,7 +4682,7 @@ def _build_view_update_payload(
|
|
|
2600
4682
|
payload.setdefault("beingShowCover", False)
|
|
2601
4683
|
payload.setdefault("defaultRowHigh", "compact")
|
|
2602
4684
|
payload.setdefault("viewgraphLimitType", 1)
|
|
2603
|
-
payload.setdefault("viewgraphLimit", [])
|
|
4685
|
+
payload.setdefault("viewgraphLimit", deepcopy(view_filters) if view_filters else [])
|
|
2604
4686
|
payload.setdefault("buttonConfigDTOList", [])
|
|
2605
4687
|
|
|
2606
4688
|
normalized_type = patch.type.value
|
|
@@ -2620,7 +4702,385 @@ def _build_view_update_payload(
|
|
|
2620
4702
|
payload["beingShowTitleQue"] = True
|
|
2621
4703
|
payload["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
|
|
2622
4704
|
payload["groupQueId"] = field_map.get(patch.group_by or "")
|
|
2623
|
-
|
|
4705
|
+
elif normalized_type == "gantt":
|
|
4706
|
+
payload["viewgraphType"] = "ganttView"
|
|
4707
|
+
payload["beingShowTitleQue"] = True
|
|
4708
|
+
payload["titleQue"] = gantt_payload.get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
|
|
4709
|
+
payload.pop("groupQueId", None)
|
|
4710
|
+
return _hydrate_view_backend_payload(
|
|
4711
|
+
payload=payload,
|
|
4712
|
+
view_type=normalized_type,
|
|
4713
|
+
visible_que_id_values=visible_que_id_values,
|
|
4714
|
+
group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
|
|
4715
|
+
view_filters=view_filters,
|
|
4716
|
+
gantt_payload=gantt_payload,
|
|
4717
|
+
)
|
|
4718
|
+
|
|
4719
|
+
|
|
4720
|
+
def _build_minimal_view_payload(
|
|
4721
|
+
*,
|
|
4722
|
+
app_key: str,
|
|
4723
|
+
schema: dict[str, Any],
|
|
4724
|
+
patch: ViewUpsertPatch,
|
|
4725
|
+
ordinal: int,
|
|
4726
|
+
view_filters: list[list[dict[str, Any]]],
|
|
4727
|
+
) -> JSONObject:
|
|
4728
|
+
field_map = extract_field_map(schema)
|
|
4729
|
+
parsed_schema = _parse_schema(schema)
|
|
4730
|
+
visible_field_names = _resolve_view_visible_field_names(patch)
|
|
4731
|
+
visible_que_id_values = [field_map[name] for name in visible_field_names if name in field_map]
|
|
4732
|
+
gantt_payload = _build_public_gantt_payload(parsed_schema["fields"], field_map, patch)
|
|
4733
|
+
payload: JSONObject = {
|
|
4734
|
+
"appKey": app_key,
|
|
4735
|
+
"viewgraphName": patch.name,
|
|
4736
|
+
"viewgraphType": {
|
|
4737
|
+
"table": "tableView",
|
|
4738
|
+
"card": "cardView",
|
|
4739
|
+
"board": "boardView",
|
|
4740
|
+
"gantt": "ganttView",
|
|
4741
|
+
}[patch.type.value],
|
|
4742
|
+
"ordinal": ordinal,
|
|
4743
|
+
"viewgraphQueIds": visible_que_id_values,
|
|
4744
|
+
"viewgraphQuestions": _build_viewgraph_questions(schema, visible_que_id_values),
|
|
4745
|
+
"auth": default_member_auth(),
|
|
4746
|
+
}
|
|
4747
|
+
if patch.type.value in {"card", "board", "gantt"}:
|
|
4748
|
+
payload["beingShowTitleQue"] = True
|
|
4749
|
+
payload["titleQue"] = gantt_payload.get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
|
|
4750
|
+
if patch.type.value == "board":
|
|
4751
|
+
payload["groupQueId"] = field_map.get(patch.group_by or "")
|
|
4752
|
+
return _hydrate_view_backend_payload(
|
|
4753
|
+
payload=payload,
|
|
4754
|
+
view_type=patch.type.value,
|
|
4755
|
+
visible_que_id_values=visible_que_id_values,
|
|
4756
|
+
group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
|
|
4757
|
+
view_filters=view_filters,
|
|
4758
|
+
gantt_payload=gantt_payload,
|
|
4759
|
+
)
|
|
4760
|
+
|
|
4761
|
+
|
|
4762
|
+
def _hydrate_view_backend_payload(
|
|
4763
|
+
*,
|
|
4764
|
+
payload: JSONObject,
|
|
4765
|
+
view_type: str,
|
|
4766
|
+
visible_que_id_values: list[int],
|
|
4767
|
+
group_que_id: int | None,
|
|
4768
|
+
view_filters: list[list[dict[str, Any]]] | None = None,
|
|
4769
|
+
gantt_payload: dict[str, Any] | None = None,
|
|
4770
|
+
) -> JSONObject:
|
|
4771
|
+
data = deepcopy(payload)
|
|
4772
|
+
data.setdefault("beingPinNavigate", True)
|
|
4773
|
+
data.setdefault("beingNeedPass", False)
|
|
4774
|
+
data.setdefault("beingShowTitleQue", view_type in {"card", "board"})
|
|
4775
|
+
data.setdefault("beingShowCover", False)
|
|
4776
|
+
data.setdefault("defaultRowHigh", "compact")
|
|
4777
|
+
data.setdefault("asosChartVisible", False)
|
|
4778
|
+
data.setdefault("viewgraphPass", "")
|
|
4779
|
+
data.setdefault("beingGroupColor", False)
|
|
4780
|
+
data.setdefault("beingShowQueTitle", True)
|
|
4781
|
+
data.setdefault("beingImageAdaption", False)
|
|
4782
|
+
data.setdefault("clippingMode", "default")
|
|
4783
|
+
data.setdefault("frontCoverQueId", None)
|
|
4784
|
+
data.setdefault("viewgraphLimitType", 1)
|
|
4785
|
+
data.setdefault("viewgraphLimit", deepcopy(view_filters) if view_filters else [])
|
|
4786
|
+
data.setdefault("viewgraphLimitFormula", "")
|
|
4787
|
+
data.setdefault("sortType", "defaultSort")
|
|
4788
|
+
if not data.get("viewgraphSorts"):
|
|
4789
|
+
sort_que_id = visible_que_id_values[0] if visible_que_id_values else 1
|
|
4790
|
+
data["viewgraphSorts"] = [{"queId": sort_que_id, "beingSortAscend": True}]
|
|
4791
|
+
data.setdefault("beingAuditRecordVisible", True)
|
|
4792
|
+
data.setdefault("beingQrobotRecordVisible", False)
|
|
4793
|
+
data.setdefault("beingPrintStatus", False)
|
|
4794
|
+
data.setdefault("beingDefaultPrintTplStatus", False)
|
|
4795
|
+
data.setdefault("printTpls", [])
|
|
4796
|
+
data.setdefault("beingCommentStatus", False)
|
|
4797
|
+
data.setdefault("usages", [])
|
|
4798
|
+
data.setdefault("dataPermissionType", "CUSTOM")
|
|
4799
|
+
data.setdefault("dataScope", "ALL")
|
|
4800
|
+
data.setdefault("needPass", False)
|
|
4801
|
+
data.setdefault("beingWorkflowNodeFutureListVisible", True)
|
|
4802
|
+
data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
|
|
4803
|
+
data.setdefault("viewgraphGanttConfigVO", None)
|
|
4804
|
+
data.setdefault("viewgraphHierarchyConfigVO", None)
|
|
4805
|
+
data.setdefault("buttonConfigDTOList", [])
|
|
4806
|
+
if "buttonConfig" not in data:
|
|
4807
|
+
data["buttonConfig"] = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
|
|
4808
|
+
if view_type == "table":
|
|
4809
|
+
data["viewgraphType"] = "tableView"
|
|
4810
|
+
data["beingShowTitleQue"] = False
|
|
4811
|
+
data["titleQue"] = None
|
|
4812
|
+
data["groupQueId"] = None
|
|
4813
|
+
elif view_type == "card":
|
|
4814
|
+
data["viewgraphType"] = "cardView"
|
|
4815
|
+
data["beingShowTitleQue"] = True
|
|
4816
|
+
data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
|
|
4817
|
+
data["groupQueId"] = None
|
|
4818
|
+
elif view_type == "board":
|
|
4819
|
+
data["viewgraphType"] = "boardView"
|
|
4820
|
+
data["beingShowTitleQue"] = True
|
|
4821
|
+
data["titleQue"] = visible_que_id_values[0] if visible_que_id_values else None
|
|
4822
|
+
data["groupQueId"] = group_que_id
|
|
4823
|
+
elif view_type == "gantt":
|
|
4824
|
+
data["viewgraphType"] = "ganttView"
|
|
4825
|
+
data["beingShowTitleQue"] = True
|
|
4826
|
+
data["titleQue"] = (gantt_payload or {}).get("titleQueId") or (visible_que_id_values[0] if visible_que_id_values else None)
|
|
4827
|
+
data["groupQueId"] = None
|
|
4828
|
+
data["viewgraphGanttConfigVO"] = deepcopy(gantt_payload) if gantt_payload else None
|
|
4829
|
+
if view_filters is not None:
|
|
4830
|
+
data["viewgraphLimit"] = deepcopy(view_filters)
|
|
4831
|
+
return data
|
|
4832
|
+
|
|
4833
|
+
|
|
4834
|
+
def _resolve_view_visible_field_names(patch: ViewUpsertPatch) -> list[str]:
|
|
4835
|
+
ordered: list[str] = []
|
|
4836
|
+
for value in [*patch.columns, patch.title_field, patch.start_field, patch.end_field, patch.group_by]:
|
|
4837
|
+
name = str(value or "").strip()
|
|
4838
|
+
if name and name not in ordered:
|
|
4839
|
+
ordered.append(name)
|
|
4840
|
+
return ordered
|
|
4841
|
+
|
|
4842
|
+
|
|
4843
|
+
def _build_public_gantt_payload(
|
|
4844
|
+
fields: list[dict[str, Any]],
|
|
4845
|
+
field_map: dict[str, int],
|
|
4846
|
+
patch: ViewUpsertPatch,
|
|
4847
|
+
) -> dict[str, Any]:
|
|
4848
|
+
if patch.type.value != "gantt":
|
|
4849
|
+
return {}
|
|
4850
|
+
title_field_name = str((patch.title_field or (patch.columns[0] if patch.columns else "")) or "").strip()
|
|
4851
|
+
start_field_name = str(patch.start_field or "").strip()
|
|
4852
|
+
end_field_name = str(patch.end_field or "").strip()
|
|
4853
|
+
return {
|
|
4854
|
+
"titleQueId": field_map.get(title_field_name),
|
|
4855
|
+
"startTimeQueId": field_map.get(start_field_name),
|
|
4856
|
+
"endTimeQueId": field_map.get(end_field_name),
|
|
4857
|
+
"defaultTimeDimension": "week",
|
|
4858
|
+
"ganttGroupVOList": [],
|
|
4859
|
+
"ganttDependencyVO": {
|
|
4860
|
+
"dependencyQueId": None,
|
|
4861
|
+
"predecessorTaskQueId": None,
|
|
4862
|
+
"startEndOptionId": None,
|
|
4863
|
+
"startStartOptionId": None,
|
|
4864
|
+
"endEndOptionId": None,
|
|
4865
|
+
"endStartOptionId": None,
|
|
4866
|
+
},
|
|
4867
|
+
"ganttAutoCalibrationVO": {
|
|
4868
|
+
"autoCalibrationRuleVO": {
|
|
4869
|
+
"startStartBegin": False,
|
|
4870
|
+
"startEndBegin": False,
|
|
4871
|
+
"startEndFinish": False,
|
|
4872
|
+
"endStartBegin": True,
|
|
4873
|
+
"endStartFinish": True,
|
|
4874
|
+
"endEndFinish": False,
|
|
4875
|
+
},
|
|
4876
|
+
"beingAutoCalibration": False,
|
|
4877
|
+
"userAutoCalibration": False,
|
|
4878
|
+
},
|
|
4879
|
+
}
|
|
4880
|
+
|
|
4881
|
+
|
|
4882
|
+
def _build_view_filter_groups(
|
|
4883
|
+
*,
|
|
4884
|
+
current_fields_by_name: dict[str, dict[str, Any]],
|
|
4885
|
+
filters: list[Any],
|
|
4886
|
+
) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
|
|
4887
|
+
translated_rules: list[dict[str, Any]] = []
|
|
4888
|
+
issues: list[dict[str, Any]] = []
|
|
4889
|
+
for raw_rule in filters:
|
|
4890
|
+
if hasattr(raw_rule, "model_dump"):
|
|
4891
|
+
raw_rule = raw_rule.model_dump(mode="json")
|
|
4892
|
+
if not isinstance(raw_rule, dict):
|
|
4893
|
+
continue
|
|
4894
|
+
field_name = str(raw_rule.get("field_name") or "").strip()
|
|
4895
|
+
field = current_fields_by_name.get(field_name)
|
|
4896
|
+
if field is None:
|
|
4897
|
+
issues.append(
|
|
4898
|
+
{
|
|
4899
|
+
"error_code": "UNKNOWN_VIEW_FIELD",
|
|
4900
|
+
"missing_fields": [field_name] if field_name else [],
|
|
4901
|
+
"reason_path": "filters[].field_name",
|
|
4902
|
+
}
|
|
4903
|
+
)
|
|
4904
|
+
continue
|
|
4905
|
+
translated_rule, issue = _translate_view_filter_rule(field=field, rule=raw_rule)
|
|
4906
|
+
if issue:
|
|
4907
|
+
issues.append(issue)
|
|
4908
|
+
continue
|
|
4909
|
+
translated_rules.append(translated_rule)
|
|
4910
|
+
return ([translated_rules] if translated_rules else []), issues
|
|
4911
|
+
|
|
4912
|
+
|
|
4913
|
+
def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
4914
|
+
operator = str(rule.get("operator") or "").strip().lower()
|
|
4915
|
+
values = list(rule.get("values") or [])
|
|
4916
|
+
field_type = str(field.get("type") or FieldType.text.value)
|
|
4917
|
+
que_id = _coerce_positive_int(field.get("que_id")) or 0
|
|
4918
|
+
que_type = _coerce_positive_int(field.get("que_type")) or FIELD_TYPE_TO_QUESTION_TYPE.get(field_type, 2)
|
|
4919
|
+
judge_values, judge_value_details, issue = _resolve_view_filter_values(field=field, values=values)
|
|
4920
|
+
if issue:
|
|
4921
|
+
return {}, issue
|
|
4922
|
+
payload: dict[str, Any] = {
|
|
4923
|
+
"queId": que_id,
|
|
4924
|
+
"queTitle": str(field.get("name") or ""),
|
|
4925
|
+
"queType": que_type,
|
|
4926
|
+
"matchType": MATCH_TYPE_ACCURACY,
|
|
4927
|
+
"judgeValueDetails": judge_value_details,
|
|
4928
|
+
}
|
|
4929
|
+
if operator == "eq":
|
|
4930
|
+
payload["judgeType"] = JUDGE_EQUAL
|
|
4931
|
+
payload["judgeValues"] = judge_values[:1] if judge_values else []
|
|
4932
|
+
elif operator == "neq":
|
|
4933
|
+
payload["judgeType"] = JUDGE_UNEQUAL
|
|
4934
|
+
payload["judgeValues"] = judge_values[:1] if judge_values else []
|
|
4935
|
+
elif operator == "in":
|
|
4936
|
+
payload["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
|
|
4937
|
+
payload["judgeValues"] = judge_values
|
|
4938
|
+
elif operator == "contains":
|
|
4939
|
+
payload["judgeType"] = JUDGE_FUZZY_MATCH
|
|
4940
|
+
payload["judgeValues"] = judge_values[:1] if judge_values else []
|
|
4941
|
+
elif operator == "gte":
|
|
4942
|
+
payload["judgeType"] = JUDGE_GREATER_OR_EQUAL
|
|
4943
|
+
payload["judgeValues"] = judge_values[:1] if judge_values else []
|
|
4944
|
+
elif operator == "lte":
|
|
4945
|
+
payload["judgeType"] = JUDGE_LESS_OR_EQUAL
|
|
4946
|
+
payload["judgeValues"] = judge_values[:1] if judge_values else []
|
|
4947
|
+
elif operator == "is_empty":
|
|
4948
|
+
payload["judgeType"] = JUDGE_EQUAL
|
|
4949
|
+
payload["judgeValues"] = []
|
|
4950
|
+
elif operator == "not_empty":
|
|
4951
|
+
payload["judgeType"] = JUDGE_UNEQUAL
|
|
4952
|
+
payload["judgeValues"] = []
|
|
4953
|
+
return payload, None
|
|
4954
|
+
|
|
4955
|
+
|
|
4956
|
+
def _resolve_view_filter_values(
|
|
4957
|
+
*,
|
|
4958
|
+
field: dict[str, Any],
|
|
4959
|
+
values: list[Any],
|
|
4960
|
+
) -> tuple[list[str], list[dict[str, Any]], dict[str, Any] | None]:
|
|
4961
|
+
field_type = str(field.get("type") or FieldType.text.value)
|
|
4962
|
+
if field_type not in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
|
|
4963
|
+
return [_stringify_condition_value(value) for value in values], _build_view_filter_value_details(values), None
|
|
4964
|
+
|
|
4965
|
+
option_details = [
|
|
4966
|
+
item
|
|
4967
|
+
for item in (field.get("option_details") or [])
|
|
4968
|
+
if isinstance(item, dict) and item.get("id") is not None and item.get("value") is not None
|
|
4969
|
+
]
|
|
4970
|
+
if not option_details:
|
|
4971
|
+
return [_stringify_condition_value(value) for value in values], _build_view_filter_value_details(values), None
|
|
4972
|
+
|
|
4973
|
+
option_by_value = {str(item.get("value") or "").strip(): item for item in option_details if str(item.get("value") or "").strip()}
|
|
4974
|
+
option_by_id = {str(item.get("id")): item for item in option_details if item.get("id") is not None}
|
|
4975
|
+
resolved_details: list[dict[str, Any]] = []
|
|
4976
|
+
unresolved_values: list[str] = []
|
|
4977
|
+
for value in values:
|
|
4978
|
+
resolved = _resolve_view_filter_option_detail(value=value, option_by_value=option_by_value, option_by_id=option_by_id)
|
|
4979
|
+
if resolved is None:
|
|
4980
|
+
unresolved_values.append(_stringify_condition_value(value))
|
|
4981
|
+
continue
|
|
4982
|
+
resolved_details.append({"id": resolved.get("id"), "value": str(resolved.get("value") or resolved.get("id"))})
|
|
4983
|
+
if unresolved_values:
|
|
4984
|
+
return (
|
|
4985
|
+
[],
|
|
4986
|
+
[],
|
|
4987
|
+
{
|
|
4988
|
+
"error_code": "UNKNOWN_VIEW_FILTER_VALUE",
|
|
4989
|
+
"reason_path": "filters[].values",
|
|
4990
|
+
"missing_fields": [],
|
|
4991
|
+
"allowed_values": {"filter_values": sorted(option_by_value.keys())},
|
|
4992
|
+
"details": {
|
|
4993
|
+
"field_name": str(field.get("name") or ""),
|
|
4994
|
+
"unresolved_values": unresolved_values,
|
|
4995
|
+
},
|
|
4996
|
+
},
|
|
4997
|
+
)
|
|
4998
|
+
return (
|
|
4999
|
+
[str(detail["id"]) for detail in resolved_details if detail.get("id") is not None],
|
|
5000
|
+
[_coerce_view_filter_value_detail(detail) for detail in resolved_details],
|
|
5001
|
+
None,
|
|
5002
|
+
)
|
|
5003
|
+
|
|
5004
|
+
|
|
5005
|
+
def _resolve_view_filter_option_detail(
|
|
5006
|
+
*,
|
|
5007
|
+
value: Any,
|
|
5008
|
+
option_by_value: dict[str, dict[str, Any]],
|
|
5009
|
+
option_by_id: dict[str, dict[str, Any]],
|
|
5010
|
+
) -> dict[str, Any] | None:
|
|
5011
|
+
if isinstance(value, dict):
|
|
5012
|
+
item_id = value.get("id")
|
|
5013
|
+
if item_id is not None:
|
|
5014
|
+
matched = option_by_id.get(str(item_id))
|
|
5015
|
+
if matched is not None:
|
|
5016
|
+
return matched
|
|
5017
|
+
item_value = str(value.get("value") or "").strip()
|
|
5018
|
+
if item_value:
|
|
5019
|
+
return option_by_value.get(item_value)
|
|
5020
|
+
return None
|
|
5021
|
+
if isinstance(value, int):
|
|
5022
|
+
return option_by_id.get(str(value))
|
|
5023
|
+
normalized = str(value or "").strip()
|
|
5024
|
+
if not normalized:
|
|
5025
|
+
return None
|
|
5026
|
+
return option_by_value.get(normalized) or option_by_id.get(normalized)
|
|
5027
|
+
|
|
5028
|
+
|
|
5029
|
+
def _build_view_filter_value_details(values: list[Any]) -> list[dict[str, Any]]:
|
|
5030
|
+
details: list[dict[str, Any]] = []
|
|
5031
|
+
for value in values:
|
|
5032
|
+
if isinstance(value, dict):
|
|
5033
|
+
item_id = value.get("id")
|
|
5034
|
+
if item_id is None:
|
|
5035
|
+
continue
|
|
5036
|
+
item_value = value.get("value")
|
|
5037
|
+
details.append(_coerce_view_filter_value_detail({"id": item_id, "value": item_value if item_value is not None else str(item_id)}))
|
|
5038
|
+
elif isinstance(value, int):
|
|
5039
|
+
details.append(_coerce_view_filter_value_detail({"id": value, "value": str(value)}))
|
|
5040
|
+
return details
|
|
5041
|
+
|
|
5042
|
+
|
|
5043
|
+
def _coerce_view_filter_value_detail(value: dict[str, Any]) -> dict[str, Any]:
|
|
5044
|
+
item_id = value.get("id")
|
|
5045
|
+
item_value = value.get("value")
|
|
5046
|
+
return {
|
|
5047
|
+
"id": item_id,
|
|
5048
|
+
"value": item_value if item_value is not None else (str(item_id) if item_id is not None else None),
|
|
5049
|
+
"dataValue": value.get("dataValue"),
|
|
5050
|
+
"email": value.get("email"),
|
|
5051
|
+
"optionId": value.get("optionId"),
|
|
5052
|
+
"ordinal": value.get("ordinal"),
|
|
5053
|
+
"otherInfo": value.get("otherInfo"),
|
|
5054
|
+
"pluginValue": value.get("pluginValue"),
|
|
5055
|
+
"queId": value.get("queId"),
|
|
5056
|
+
}
|
|
5057
|
+
|
|
5058
|
+
|
|
5059
|
+
def _normalize_view_filter_groups_for_compare(groups: Any) -> list[list[dict[str, Any]]]:
|
|
5060
|
+
normalized_groups: list[list[dict[str, Any]]] = []
|
|
5061
|
+
for raw_group in groups or []:
|
|
5062
|
+
if not isinstance(raw_group, list):
|
|
5063
|
+
continue
|
|
5064
|
+
normalized_rules: list[dict[str, Any]] = []
|
|
5065
|
+
for raw_rule in raw_group:
|
|
5066
|
+
if not isinstance(raw_rule, dict):
|
|
5067
|
+
continue
|
|
5068
|
+
normalized_details = []
|
|
5069
|
+
for detail in raw_rule.get("judgeValueDetails") or []:
|
|
5070
|
+
if not isinstance(detail, dict) or detail.get("id") is None:
|
|
5071
|
+
continue
|
|
5072
|
+
normalized_details.append({"id": detail.get("id"), "value": str(detail.get("value") or detail.get("id"))})
|
|
5073
|
+
normalized_rules.append(
|
|
5074
|
+
{
|
|
5075
|
+
"queId": _coerce_positive_int(raw_rule.get("queId")) or 0,
|
|
5076
|
+
"judgeType": _coerce_positive_int(raw_rule.get("judgeType")) or 0,
|
|
5077
|
+
"judgeValues": [str(value) for value in (raw_rule.get("judgeValues") or [])],
|
|
5078
|
+
"judgeValueDetails": normalized_details,
|
|
5079
|
+
}
|
|
5080
|
+
)
|
|
5081
|
+
if normalized_rules:
|
|
5082
|
+
normalized_groups.append(normalized_rules)
|
|
5083
|
+
return normalized_groups
|
|
2624
5084
|
|
|
2625
5085
|
|
|
2626
5086
|
def _infer_status_field_id(fields: list[dict[str, Any]]) -> str | None:
|
|
@@ -2654,6 +5114,7 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
|
|
|
2654
5114
|
request_id = first_error.get("request_id")
|
|
2655
5115
|
backend_code = first_error.get("backend_code")
|
|
2656
5116
|
http_status = first_error.get("http_status")
|
|
5117
|
+
lowered_detail = detail_text.lower()
|
|
2657
5118
|
if "must declare status field" in detail_text:
|
|
2658
5119
|
return _failed(
|
|
2659
5120
|
"STATUS_FIELD_REQUIRED",
|
|
@@ -2686,14 +5147,45 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
|
|
|
2686
5147
|
backend_code=backend_code,
|
|
2687
5148
|
http_status=http_status,
|
|
2688
5149
|
)
|
|
5150
|
+
if (
|
|
5151
|
+
"run solution_build_app first" in lowered_detail
|
|
5152
|
+
or "run solution_build_app_flow first" in lowered_detail
|
|
5153
|
+
or ("is not defined yet" in lowered_detail and "solution_build_" in lowered_detail)
|
|
5154
|
+
):
|
|
5155
|
+
return _failed(
|
|
5156
|
+
"FLOW_STAGE_CONTEXT_MISSING",
|
|
5157
|
+
"workflow apply lost the app context required by the internal flow builder",
|
|
5158
|
+
details={
|
|
5159
|
+
"app_key": app_key,
|
|
5160
|
+
"entity_id": entity.get("entity_id"),
|
|
5161
|
+
"existing_fields": entity.get("fields") or [],
|
|
5162
|
+
"internal_detail": detail_text,
|
|
5163
|
+
"stage_result": public_stage_result,
|
|
5164
|
+
},
|
|
5165
|
+
suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5166
|
+
request_id=request_id,
|
|
5167
|
+
backend_code=backend_code,
|
|
5168
|
+
http_status=http_status,
|
|
5169
|
+
)
|
|
5170
|
+
message = detail_text or "failed to apply workflow patch"
|
|
5171
|
+
details = {"app_key": app_key, "entity_id": entity.get("entity_id"), "stage_result": public_stage_result}
|
|
5172
|
+
public_http_status = http_status
|
|
5173
|
+
if http_status == 404:
|
|
5174
|
+
message = "workflow write route is unavailable for this app in the current route"
|
|
5175
|
+
details["transport_error"] = {
|
|
5176
|
+
"http_status": http_status,
|
|
5177
|
+
"backend_code": backend_code,
|
|
5178
|
+
"category": "http",
|
|
5179
|
+
}
|
|
5180
|
+
public_http_status = None
|
|
2689
5181
|
return _failed(
|
|
2690
5182
|
stage_error_code,
|
|
2691
|
-
|
|
2692
|
-
details=
|
|
5183
|
+
message,
|
|
5184
|
+
details=details,
|
|
2693
5185
|
suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2694
5186
|
request_id=request_id,
|
|
2695
5187
|
backend_code=backend_code,
|
|
2696
|
-
http_status=
|
|
5188
|
+
http_status=public_http_status,
|
|
2697
5189
|
)
|
|
2698
5190
|
|
|
2699
5191
|
|