@josephyan/qingflow-cli 0.2.0-beta.76 → 0.2.0-beta.78

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.
@@ -460,6 +460,8 @@ class RecordTools(ToolBase):
460
460
  app_key: str = "",
461
461
  record_id: int | None = None,
462
462
  fields: JSONObject | None = None,
463
+ items: list[JSONObject] | None = None,
464
+ dry_run: bool = False,
463
465
  verify_write: bool = True,
464
466
  output_profile: str = "normal",
465
467
  ) -> JSONObject:
@@ -467,7 +469,9 @@ class RecordTools(ToolBase):
467
469
  profile=DEFAULT_PROFILE,
468
470
  app_key=app_key,
469
471
  record_id=record_id,
470
- fields=fields or {},
472
+ fields=fields,
473
+ items=items,
474
+ dry_run=dry_run,
471
475
  verify_write=verify_write,
472
476
  output_profile=output_profile,
473
477
  )
@@ -1640,6 +1644,8 @@ class RecordTools(ToolBase):
1640
1644
  ),
1641
1645
  }
1642
1646
  )
1647
+ rows = list_data.get("rows", [])
1648
+ normalized_public_rows = _normalize_public_record_rows(rows if isinstance(rows, list) else [])
1643
1649
  response: JSONObject = {
1644
1650
  "profile": profile,
1645
1651
  "ws_id": raw.get("ws_id"),
@@ -1650,7 +1656,7 @@ class RecordTools(ToolBase):
1650
1656
  "output_profile": normalized_output_profile,
1651
1657
  "data": {
1652
1658
  "app_key": app_key,
1653
- "items": list_data.get("rows", []),
1659
+ "items": normalized_public_rows,
1654
1660
  "pagination": {
1655
1661
  "page": page,
1656
1662
  "limit": limit,
@@ -1818,7 +1824,7 @@ class RecordTools(ToolBase):
1818
1824
  "output_profile": normalized_output_profile,
1819
1825
  "data": {
1820
1826
  "app_key": app_key,
1821
- "record_id": record_id,
1827
+ "record_id": _public_record_id_text(record_id),
1822
1828
  "record": row,
1823
1829
  "selection": {
1824
1830
  "columns": [_column_selector_payload(field_id) for field_id in normalized_columns] if normalized_columns else [],
@@ -1918,21 +1924,60 @@ class RecordTools(ToolBase):
1918
1924
  app_key: str,
1919
1925
  record_id: int | None,
1920
1926
  fields: JSONObject | None = None,
1927
+ items: list[JSONObject] | None = None,
1928
+ dry_run: bool = False,
1921
1929
  verify_write: bool = True,
1922
1930
  output_profile: str = "normal",
1923
1931
  ) -> JSONObject:
1924
1932
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
1925
1933
  if not app_key:
1926
1934
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
1935
+ if items is not None:
1936
+ if dry_run not in {True, False}:
1937
+ raise_tool_error(QingflowApiError.config_error("dry_run must be boolean"))
1938
+ normalized_items = self._normalize_public_record_update_batch_items(
1939
+ record_id=record_id,
1940
+ fields=fields,
1941
+ items=items,
1942
+ )
1943
+ return self._record_update_public_batch(
1944
+ profile=profile,
1945
+ app_key=app_key,
1946
+ items=normalized_items,
1947
+ dry_run=dry_run,
1948
+ verify_write=verify_write,
1949
+ output_profile=normalized_output_profile,
1950
+ )
1951
+ if dry_run:
1952
+ raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
1927
1953
  if record_id is None or record_id <= 0:
1928
1954
  raise_tool_error(QingflowApiError.config_error("record_id is required"))
1929
1955
  if fields is not None and not isinstance(fields, dict):
1930
1956
  raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
1931
- raw_preflight = self._preflight_record_update_with_auto_view(
1957
+ return self._record_update_public_single(
1932
1958
  profile=profile,
1933
1959
  app_key=app_key,
1934
1960
  record_id=record_id,
1935
1961
  fields=cast(JSONObject, fields or {}),
1962
+ verify_write=verify_write,
1963
+ output_profile=normalized_output_profile,
1964
+ )
1965
+
1966
+ def _record_update_public_single(
1967
+ self,
1968
+ *,
1969
+ profile: str,
1970
+ app_key: str,
1971
+ record_id: int,
1972
+ fields: JSONObject,
1973
+ verify_write: bool,
1974
+ output_profile: str,
1975
+ ) -> JSONObject:
1976
+ raw_preflight = self._preflight_record_update_with_auto_view(
1977
+ profile=profile,
1978
+ app_key=app_key,
1979
+ record_id=record_id,
1980
+ fields=fields,
1936
1981
  force_refresh_form=False,
1937
1982
  )
1938
1983
  preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
@@ -1950,7 +1995,7 @@ class RecordTools(ToolBase):
1950
1995
  raw_preflight,
1951
1996
  operation="update",
1952
1997
  normalized_payload=normalized_payload,
1953
- output_profile=normalized_output_profile,
1998
+ output_profile=output_profile,
1954
1999
  human_review=True,
1955
2000
  target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
1956
2001
  )
@@ -1978,11 +2023,257 @@ class RecordTools(ToolBase):
1978
2023
  raw_apply,
1979
2024
  operation="update",
1980
2025
  normalized_payload=normalized_payload,
1981
- output_profile=normalized_output_profile,
2026
+ output_profile=output_profile,
1982
2027
  human_review=True,
1983
2028
  preflight=raw_preflight,
1984
2029
  )
1985
2030
 
2031
+ def _record_update_public_batch(
2032
+ self,
2033
+ *,
2034
+ profile: str,
2035
+ app_key: str,
2036
+ items: list[JSONObject],
2037
+ dry_run: bool,
2038
+ verify_write: bool,
2039
+ output_profile: str,
2040
+ ) -> JSONObject:
2041
+ preflight_responses = [
2042
+ self._record_update_public_preflight_response(
2043
+ profile=profile,
2044
+ app_key=app_key,
2045
+ record_id=cast(int, item["record_id"]),
2046
+ fields=cast(JSONObject, item["fields"]),
2047
+ output_profile=output_profile,
2048
+ )
2049
+ for item in items
2050
+ ]
2051
+ has_preflight_blockers = any(
2052
+ str(response.get("status") or "").lower() in {"blocked", "needs_confirmation"}
2053
+ for response in preflight_responses
2054
+ )
2055
+ if dry_run or has_preflight_blockers:
2056
+ return self._record_update_batch_response(
2057
+ profile=profile,
2058
+ app_key=app_key,
2059
+ responses=preflight_responses,
2060
+ output_profile=output_profile,
2061
+ dry_run=dry_run,
2062
+ )
2063
+
2064
+ apply_responses: list[JSONObject] = []
2065
+ for item in items:
2066
+ record_id = cast(int, item["record_id"])
2067
+ fields = cast(JSONObject, item["fields"])
2068
+ try:
2069
+ apply_responses.append(
2070
+ self._record_update_public_single(
2071
+ profile=profile,
2072
+ app_key=app_key,
2073
+ record_id=record_id,
2074
+ fields=fields,
2075
+ verify_write=verify_write,
2076
+ output_profile=output_profile,
2077
+ )
2078
+ )
2079
+ except (QingflowApiError, RuntimeError) as exc:
2080
+ apply_responses.append(
2081
+ self._record_write_exception_response(
2082
+ exc,
2083
+ operation="update",
2084
+ profile=profile,
2085
+ app_key=app_key,
2086
+ record_id=record_id,
2087
+ output_profile=output_profile,
2088
+ human_review=True,
2089
+ )
2090
+ )
2091
+
2092
+ return self._record_update_batch_response(
2093
+ profile=profile,
2094
+ app_key=app_key,
2095
+ responses=apply_responses,
2096
+ output_profile=output_profile,
2097
+ dry_run=False,
2098
+ )
2099
+
2100
+ def _record_update_public_preflight_response(
2101
+ self,
2102
+ *,
2103
+ profile: str,
2104
+ app_key: str,
2105
+ record_id: int,
2106
+ fields: JSONObject,
2107
+ output_profile: str,
2108
+ ) -> JSONObject:
2109
+ raw_preflight = self._preflight_record_update_with_auto_view(
2110
+ profile=profile,
2111
+ app_key=app_key,
2112
+ record_id=record_id,
2113
+ fields=fields,
2114
+ force_refresh_form=False,
2115
+ )
2116
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
2117
+ normalized_payload = self._record_write_normalized_payload(
2118
+ operation="update",
2119
+ record_id=record_id,
2120
+ record_ids=[],
2121
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
2122
+ submit_type=1,
2123
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
2124
+ )
2125
+ confirmation_requests = cast(list[JSONObject], preflight_data.get("confirmation_requests", []))
2126
+ if preflight_data.get("blockers") or confirmation_requests:
2127
+ return self._record_write_blocked_response(
2128
+ raw_preflight,
2129
+ operation="update",
2130
+ normalized_payload=normalized_payload,
2131
+ output_profile=output_profile,
2132
+ human_review=True,
2133
+ target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
2134
+ )
2135
+ return self._record_write_ready_response(
2136
+ raw_preflight,
2137
+ operation="update",
2138
+ normalized_payload=normalized_payload,
2139
+ output_profile=output_profile,
2140
+ human_review=True,
2141
+ target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
2142
+ )
2143
+
2144
+ def _normalize_public_record_update_batch_items(
2145
+ self,
2146
+ *,
2147
+ record_id: int | None,
2148
+ fields: JSONObject | None,
2149
+ items: list[JSONObject] | None,
2150
+ ) -> list[JSONObject]:
2151
+ if record_id is not None or fields is not None:
2152
+ raise_tool_error(
2153
+ QingflowApiError.config_error("record_update batch mode does not accept record_id or fields")
2154
+ )
2155
+ if not isinstance(items, list) or not items:
2156
+ raise_tool_error(QingflowApiError.config_error("items must be a non-empty list"))
2157
+ normalized_items: list[JSONObject] = []
2158
+ seen_record_ids: set[int] = set()
2159
+ for index, item in enumerate(items):
2160
+ if not isinstance(item, dict):
2161
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}] must be an object"))
2162
+ normalized_record_id = _coerce_count(item.get("record_id"))
2163
+ if normalized_record_id is None or normalized_record_id <= 0:
2164
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}].record_id must be a positive integer"))
2165
+ if normalized_record_id in seen_record_ids:
2166
+ raise_tool_error(
2167
+ QingflowApiError.config_error(f"duplicate record_id in items: {normalized_record_id}")
2168
+ )
2169
+ item_fields = item.get("fields")
2170
+ if not isinstance(item_fields, dict):
2171
+ raise_tool_error(QingflowApiError.config_error(f"items[{index}].fields must be an object map keyed by field title"))
2172
+ seen_record_ids.add(normalized_record_id)
2173
+ normalized_items.append({"record_id": normalized_record_id, "fields": cast(JSONObject, item_fields)})
2174
+ return normalized_items
2175
+
2176
+ def _record_update_batch_response(
2177
+ self,
2178
+ *,
2179
+ profile: str,
2180
+ app_key: str,
2181
+ responses: list[JSONObject],
2182
+ output_profile: str,
2183
+ dry_run: bool,
2184
+ ) -> JSONObject:
2185
+ summary = self._record_update_batch_summary(responses)
2186
+ batch_items = [self._record_update_batch_item_from_response(response, output_profile=output_profile) for response in responses]
2187
+ status, ok, message = self._record_update_batch_envelope_status(summary=summary, dry_run=dry_run)
2188
+ first_response = responses[0] if responses else {}
2189
+ return {
2190
+ "profile": first_response.get("profile", profile),
2191
+ "ws_id": first_response.get("ws_id"),
2192
+ "ok": ok,
2193
+ "status": status,
2194
+ "request_route": first_response.get("request_route"),
2195
+ "warnings": [],
2196
+ "output_profile": output_profile,
2197
+ "data": {
2198
+ "app_key": app_key,
2199
+ "mode": "batch",
2200
+ "dry_run": dry_run,
2201
+ "summary": summary,
2202
+ "items": batch_items,
2203
+ },
2204
+ "message": message,
2205
+ }
2206
+
2207
+ def _record_update_batch_summary(self, responses: list[JSONObject]) -> JSONObject:
2208
+ summary: JSONObject = {
2209
+ "total": len(responses),
2210
+ "ready_count": 0,
2211
+ "blocked_count": 0,
2212
+ "confirmation_count": 0,
2213
+ "applied_count": 0,
2214
+ "verified_count": 0,
2215
+ "field_level_verified_count": 0,
2216
+ "failed_count": 0,
2217
+ }
2218
+ for response in responses:
2219
+ status = str(response.get("status") or "").lower()
2220
+ data = cast(JSONObject, response.get("data", {}))
2221
+ action = cast(JSONObject, data.get("action", {}))
2222
+ verification = cast(JSONObject, data.get("verification", {})) if isinstance(data.get("verification"), dict) else {}
2223
+ executed = bool(action.get("executed"))
2224
+ if status == "ready":
2225
+ summary["ready_count"] = int(summary["ready_count"]) + 1
2226
+ continue
2227
+ if status == "blocked":
2228
+ summary["blocked_count"] = int(summary["blocked_count"]) + 1
2229
+ continue
2230
+ if status == "needs_confirmation":
2231
+ summary["confirmation_count"] = int(summary["confirmation_count"]) + 1
2232
+ continue
2233
+ if executed:
2234
+ summary["applied_count"] = int(summary["applied_count"]) + 1
2235
+ if bool(verification.get("verified")):
2236
+ summary["verified_count"] = int(summary["verified_count"]) + 1
2237
+ if bool(verification.get("field_level_verified")):
2238
+ summary["field_level_verified_count"] = int(summary["field_level_verified_count"]) + 1
2239
+ if status not in {"success"}:
2240
+ summary["failed_count"] = int(summary["failed_count"]) + 1
2241
+ return summary
2242
+
2243
+ def _record_update_batch_envelope_status(self, *, summary: JSONObject, dry_run: bool) -> tuple[str, bool, str]:
2244
+ if int(summary["blocked_count"]) > 0:
2245
+ return "blocked", False, "batch update preflight blocked"
2246
+ if int(summary["confirmation_count"]) > 0:
2247
+ return "needs_confirmation", False, "batch update requires confirmation before write"
2248
+ if dry_run:
2249
+ return "ready", True, "batch update dry run ready"
2250
+ if int(summary["failed_count"]) > 0:
2251
+ return "partial_success", False, "batch update completed with partial failures"
2252
+ return "success", True, "batch update completed"
2253
+
2254
+ def _record_update_batch_item_from_response(self, response: JSONObject, *, output_profile: str) -> JSONObject:
2255
+ data = cast(JSONObject, response.get("data", {}))
2256
+ item: JSONObject = {
2257
+ "resource": data.get("resource"),
2258
+ "status": response.get("status"),
2259
+ "verification": data.get("verification"),
2260
+ "field_errors": cast(list[JSONObject], data.get("field_errors", [])),
2261
+ "confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
2262
+ "resolved_fields": cast(list[JSONObject], data.get("resolved_fields", [])),
2263
+ }
2264
+ blockers = data.get("blockers")
2265
+ if isinstance(blockers, list) and blockers:
2266
+ item["blockers"] = blockers
2267
+ warnings = response.get("warnings")
2268
+ if isinstance(warnings, list) and warnings:
2269
+ item["warnings"] = warnings
2270
+ error = data.get("error")
2271
+ if isinstance(error, dict):
2272
+ item["error"] = error
2273
+ if output_profile == "verbose" and isinstance(data.get("debug"), dict):
2274
+ item["debug"] = data.get("debug")
2275
+ return item
2276
+
1986
2277
  def _preflight_record_update_with_auto_view(
1987
2278
  self,
1988
2279
  *,
@@ -7944,6 +8235,48 @@ class RecordTools(ToolBase):
7944
8235
  }
7945
8236
  return response
7946
8237
 
8238
+ def _record_write_ready_response(
8239
+ self,
8240
+ raw_preflight: JSONObject,
8241
+ *,
8242
+ operation: str,
8243
+ normalized_payload: JSONObject,
8244
+ output_profile: str,
8245
+ human_review: bool,
8246
+ target_resource: JSONObject,
8247
+ ) -> JSONObject:
8248
+ plan_data = cast(JSONObject, raw_preflight.get("data", {}))
8249
+ validation = cast(JSONObject, plan_data.get("validation", {}))
8250
+ resolved_fields = cast(list[JSONObject], plan_data.get("lookup_resolved_fields", []))
8251
+ warnings_payload = validation.get("warnings", [])
8252
+ warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
8253
+ response: JSONObject = {
8254
+ "profile": raw_preflight.get("profile"),
8255
+ "ws_id": raw_preflight.get("ws_id"),
8256
+ "ok": True,
8257
+ "status": "ready",
8258
+ "request_route": raw_preflight.get("request_route"),
8259
+ "warnings": warnings,
8260
+ "output_profile": output_profile,
8261
+ "data": {
8262
+ "action": {"operation": operation, "executed": False},
8263
+ "resource": target_resource,
8264
+ "verification": None,
8265
+ "normalized_payload": normalized_payload,
8266
+ "blockers": [],
8267
+ "field_errors": [],
8268
+ "confirmation_requests": [],
8269
+ "resolved_fields": resolved_fields,
8270
+ "recommended_next_actions": cast(list[JSONValue], plan_data.get("recommended_next_actions", [])),
8271
+ "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
8272
+ },
8273
+ }
8274
+ if output_profile == "verbose":
8275
+ response["data"]["debug"] = {
8276
+ "preflight": plan_data,
8277
+ }
8278
+ return response
8279
+
7947
8280
  def _record_write_apply_response(
7948
8281
  self,
7949
8282
  raw_apply: JSONObject,
@@ -7994,6 +8327,67 @@ class RecordTools(ToolBase):
7994
8327
  response["data"]["debug"] = debug
7995
8328
  return response
7996
8329
 
8330
+ def _record_write_exception_response(
8331
+ self,
8332
+ exc: QingflowApiError | RuntimeError,
8333
+ *,
8334
+ operation: str,
8335
+ profile: str,
8336
+ app_key: str,
8337
+ record_id: int,
8338
+ output_profile: str,
8339
+ human_review: bool,
8340
+ ) -> JSONObject:
8341
+ error_payload: JSONObject = {
8342
+ "error_code": "RECORD_WRITE_EXECUTION_FAILED",
8343
+ "message": str(exc),
8344
+ }
8345
+ request_route: JSONObject | None = None
8346
+ if isinstance(exc, QingflowApiError):
8347
+ error_payload["message"] = exc.message
8348
+ if exc.backend_code is not None:
8349
+ error_payload["backend_code"] = exc.backend_code
8350
+ if exc.request_id is not None:
8351
+ error_payload["request_id"] = exc.request_id
8352
+ else:
8353
+ try:
8354
+ parsed = json.loads(str(exc))
8355
+ except json.JSONDecodeError:
8356
+ parsed = None
8357
+ if isinstance(parsed, dict):
8358
+ error_payload["error_code"] = parsed.get("error_code") or cast(JSONObject, parsed.get("details", {})).get("error_code") or error_payload["error_code"]
8359
+ error_payload["message"] = parsed.get("message") or error_payload["message"]
8360
+ if parsed.get("backend_code") is not None:
8361
+ error_payload["backend_code"] = parsed.get("backend_code")
8362
+ if parsed.get("request_id") is not None:
8363
+ error_payload["request_id"] = parsed.get("request_id")
8364
+ if isinstance(parsed.get("request_route"), dict):
8365
+ request_route = cast(JSONObject, parsed.get("request_route"))
8366
+ response: JSONObject = {
8367
+ "profile": profile,
8368
+ "ws_id": None,
8369
+ "ok": False,
8370
+ "status": "failed",
8371
+ "request_route": request_route,
8372
+ "warnings": [],
8373
+ "output_profile": output_profile,
8374
+ "data": {
8375
+ "action": {"operation": operation, "executed": True},
8376
+ "resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
8377
+ "verification": None,
8378
+ "normalized_payload": None,
8379
+ "blockers": [],
8380
+ "field_errors": [],
8381
+ "confirmation_requests": [],
8382
+ "resolved_fields": [],
8383
+ "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
8384
+ "error": error_payload,
8385
+ },
8386
+ }
8387
+ if output_profile == "verbose":
8388
+ response["data"]["debug"] = {"exception": error_payload}
8389
+ return response
8390
+
7997
8391
  def _record_write_public_field_errors(self, plan_data: JSONObject) -> list[JSONObject]:
7998
8392
  existing = plan_data.get("field_errors")
7999
8393
  if isinstance(existing, list):
@@ -10221,12 +10615,36 @@ def _view_selection_supported_by_search_ids(view_selection: ViewSelection, searc
10221
10615
 
10222
10616
 
10223
10617
  def _build_flat_row(answer_list: list[JSONValue], fields: list[FormField], *, apply_id: int | None) -> JSONObject:
10224
- row: JSONObject = {"apply_id": apply_id}
10618
+ public_record_id = _public_record_id_text(apply_id)
10619
+ row: JSONObject = {"apply_id": public_record_id, "record_id": public_record_id}
10225
10620
  for field in fields:
10226
10621
  row[field.que_title] = _extract_field_value(answer_list, field)
10227
10622
  return row
10228
10623
 
10229
10624
 
10625
+ def _public_record_id_text(record_id: int | None) -> str | None:
10626
+ if record_id is None or record_id <= 0:
10627
+ return None
10628
+ return str(record_id)
10629
+
10630
+
10631
+ def _normalize_public_record_rows(rows: list[JSONValue]) -> list[JSONObject]:
10632
+ normalized_rows: list[JSONObject] = []
10633
+ for item in rows:
10634
+ if not isinstance(item, dict):
10635
+ continue
10636
+ row = dict(item)
10637
+ normalized_record_id = _coerce_count(row.get("apply_id"))
10638
+ if normalized_record_id is None:
10639
+ normalized_record_id = _coerce_count(row.get("record_id"))
10640
+ if normalized_record_id is not None and normalized_record_id > 0:
10641
+ public_record_id = _public_record_id_text(normalized_record_id)
10642
+ row["apply_id"] = public_record_id
10643
+ row["record_id"] = public_record_id
10644
+ normalized_rows.append(row)
10645
+ return normalized_rows
10646
+
10647
+
10230
10648
  def _merge_answer_lists_by_field_id(existing: list[JSONValue], extra: list[JSONValue]) -> list[JSONValue]:
10231
10649
  merged: dict[str, JSONValue] = {}
10232
10650
  order: list[str] = []