@josephyan/qingflow-cli 0.2.0-beta.66 → 0.2.0-beta.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +348 -2
- package/src/qingflow_mcp/builder_facade/service.py +2029 -135
- package/src/qingflow_mcp/cli/commands/builder.py +23 -0
- package/src/qingflow_mcp/cli/commands/record.py +2 -0
- package/src/qingflow_mcp/server.py +2 -1
- package/src/qingflow_mcp/server_app_builder.py +7 -2
- package/src/qingflow_mcp/server_app_user.py +2 -1
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +29 -0
- package/src/qingflow_mcp/solution/spec_models.py +2 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +162 -16
- package/src/qingflow_mcp/tools/approval_tools.py +31 -2
- package/src/qingflow_mcp/tools/code_block_tools.py +91 -14
- package/src/qingflow_mcp/tools/package_tools.py +1 -0
- package/src/qingflow_mcp/tools/record_tools.py +549 -185
- package/src/qingflow_mcp/tools/task_context_tools.py +26 -1
|
@@ -620,7 +620,8 @@ class RecordTools(ToolBase):
|
|
|
620
620
|
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
621
621
|
|
|
622
622
|
def runner(session_profile, context):
|
|
623
|
-
|
|
623
|
+
self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
|
|
624
|
+
schema = self._get_form_schema(profile, context, app_key, force_refresh=True)
|
|
624
625
|
question_relations = _collect_question_relations(schema)
|
|
625
626
|
index = _build_applicant_top_level_field_index(schema)
|
|
626
627
|
runtime_linked_field_ids = _collect_linked_required_field_ids(question_relations)
|
|
@@ -754,7 +755,8 @@ class RecordTools(ToolBase):
|
|
|
754
755
|
|
|
755
756
|
def runner(session_profile, context):
|
|
756
757
|
request_route = self._request_route_payload(context)
|
|
757
|
-
|
|
758
|
+
self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
|
|
759
|
+
app_schema = self._get_form_schema(profile, context, app_key, force_refresh=True)
|
|
758
760
|
question_relations = _collect_question_relations(app_schema)
|
|
759
761
|
app_index = _build_applicant_top_level_field_index(app_schema)
|
|
760
762
|
linked_field_ids = _collect_linked_required_field_ids(question_relations)
|
|
@@ -822,7 +824,7 @@ class RecordTools(ToolBase):
|
|
|
822
824
|
context,
|
|
823
825
|
app_key,
|
|
824
826
|
candidate,
|
|
825
|
-
force_refresh=
|
|
827
|
+
force_refresh=True,
|
|
826
828
|
)
|
|
827
829
|
index = cast(FieldIndex, browse_scope["index"])
|
|
828
830
|
browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"])
|
|
@@ -1671,6 +1673,34 @@ class RecordTools(ToolBase):
|
|
|
1671
1673
|
cast(list[JSONValue], answer_list),
|
|
1672
1674
|
selected_fields,
|
|
1673
1675
|
)
|
|
1676
|
+
if self._record_get_needs_schema_refresh(
|
|
1677
|
+
answer_list=cast(list[JSONValue], answer_list),
|
|
1678
|
+
selected_fields=selected_fields,
|
|
1679
|
+
record=row,
|
|
1680
|
+
normalized_record=normalized_record,
|
|
1681
|
+
):
|
|
1682
|
+
self._clear_record_schema_caches(
|
|
1683
|
+
profile=profile,
|
|
1684
|
+
app_key=app_key,
|
|
1685
|
+
resolved_view=resolved_view,
|
|
1686
|
+
clear_view_caches=True,
|
|
1687
|
+
)
|
|
1688
|
+
index = self._get_browse_field_index(profile, context, app_key, resolved_view, force_refresh=True)
|
|
1689
|
+
selected_fields = (
|
|
1690
|
+
self._resolve_select_columns(
|
|
1691
|
+
normalized_columns,
|
|
1692
|
+
index,
|
|
1693
|
+
max_columns=len(normalized_columns),
|
|
1694
|
+
default_limit=MAX_RECORD_COLUMN_LIMIT,
|
|
1695
|
+
)
|
|
1696
|
+
if normalized_columns
|
|
1697
|
+
else list(index.by_id.values())
|
|
1698
|
+
)
|
|
1699
|
+
row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id)
|
|
1700
|
+
normalized_record, normalized_ambiguous_fields = _build_normalized_row_from_answers(
|
|
1701
|
+
cast(list[JSONValue], answer_list),
|
|
1702
|
+
selected_fields,
|
|
1703
|
+
)
|
|
1674
1704
|
warnings: list[JSONObject] = []
|
|
1675
1705
|
warnings.extend(compatibility_warnings)
|
|
1676
1706
|
warnings.extend(_view_filter_trust_warnings(resolved_view))
|
|
@@ -1735,6 +1765,7 @@ class RecordTools(ToolBase):
|
|
|
1735
1765
|
view_key=None,
|
|
1736
1766
|
view_name=None,
|
|
1737
1767
|
)
|
|
1768
|
+
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
1738
1769
|
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
1739
1770
|
normalized_payload: JSONObject = self._record_write_normalized_payload(
|
|
1740
1771
|
operation="insert",
|
|
@@ -1761,7 +1792,7 @@ class RecordTools(ToolBase):
|
|
|
1761
1792
|
fields={},
|
|
1762
1793
|
submit_type=submit_type_value,
|
|
1763
1794
|
verify_write=verify_write,
|
|
1764
|
-
force_refresh_form=
|
|
1795
|
+
force_refresh_form=preflight_used_force_refresh,
|
|
1765
1796
|
)
|
|
1766
1797
|
except QingflowApiError as exc:
|
|
1767
1798
|
self._raise_record_write_permission_error(
|
|
@@ -1805,6 +1836,7 @@ class RecordTools(ToolBase):
|
|
|
1805
1836
|
fields=cast(JSONObject, fields or {}),
|
|
1806
1837
|
force_refresh_form=False,
|
|
1807
1838
|
)
|
|
1839
|
+
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
1808
1840
|
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
1809
1841
|
normalized_payload = self._record_write_normalized_payload(
|
|
1810
1842
|
operation="update",
|
|
@@ -1832,7 +1864,7 @@ class RecordTools(ToolBase):
|
|
|
1832
1864
|
fields={},
|
|
1833
1865
|
role=1,
|
|
1834
1866
|
verify_write=verify_write,
|
|
1835
|
-
force_refresh_form=
|
|
1867
|
+
force_refresh_form=preflight_used_force_refresh,
|
|
1836
1868
|
)
|
|
1837
1869
|
except QingflowApiError as exc:
|
|
1838
1870
|
self._raise_record_write_permission_error(
|
|
@@ -1863,178 +1895,231 @@ class RecordTools(ToolBase):
|
|
|
1863
1895
|
) -> JSONObject:
|
|
1864
1896
|
def runner(session_profile, context):
|
|
1865
1897
|
request_route = self._request_route_payload(context)
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1898
|
+
def build_once(*, effective_force_refresh: bool) -> JSONObject:
|
|
1899
|
+
try:
|
|
1900
|
+
current_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=record_id)
|
|
1901
|
+
except QingflowApiError:
|
|
1902
|
+
return {
|
|
1903
|
+
"profile": profile,
|
|
1904
|
+
"ws_id": session_profile.selected_ws_id,
|
|
1905
|
+
"ok": True,
|
|
1906
|
+
"request_route": request_route,
|
|
1907
|
+
"data": self._build_auto_view_blocked_preflight_data(
|
|
1908
|
+
app_key=app_key,
|
|
1909
|
+
record_id=record_id,
|
|
1910
|
+
blockers=["CURRENT_RECORD_CONTEXT_UNAVAILABLE"],
|
|
1911
|
+
warnings=[
|
|
1912
|
+
"update preflight could not load the current record; automatic view selection was blocked to avoid resolving lookup fields against a partial patch."
|
|
1913
|
+
],
|
|
1914
|
+
recommended_next_actions=[
|
|
1915
|
+
"Retry after the record becomes readable in the current workspace/profile context.",
|
|
1916
|
+
"Call record_update_schema_get to inspect the overall writable field set for this record after context access is restored.",
|
|
1917
|
+
],
|
|
1918
|
+
view_probe_summary=[],
|
|
1919
|
+
),
|
|
1920
|
+
}
|
|
1888
1921
|
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1922
|
+
candidate_routes = self._candidate_update_views(profile, context, app_key)
|
|
1923
|
+
probe_summary: list[JSONObject] = []
|
|
1924
|
+
matched_any = False
|
|
1925
|
+
matched_routes: list[AccessibleViewRoute] = []
|
|
1926
|
+
first_blocked_plan: JSONObject | None = None
|
|
1927
|
+
first_confirmation_plan: JSONObject | None = None
|
|
1894
1928
|
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
)
|
|
1901
|
-
if not matched_record:
|
|
1902
|
-
probe_summary.append(
|
|
1903
|
-
{
|
|
1904
|
-
"view_id": candidate.view_id,
|
|
1905
|
-
"name": candidate.name,
|
|
1906
|
-
"kind": candidate.kind,
|
|
1907
|
-
"matched_record": False,
|
|
1908
|
-
"writable_field_titles": [],
|
|
1909
|
-
"missing_field_titles": [],
|
|
1910
|
-
"context_complete": True,
|
|
1911
|
-
"selected": False,
|
|
1912
|
-
}
|
|
1929
|
+
for candidate in candidate_routes:
|
|
1930
|
+
matched_record = self._record_matches_accessible_view(
|
|
1931
|
+
context,
|
|
1932
|
+
current_answers,
|
|
1933
|
+
resolved_view=candidate,
|
|
1913
1934
|
)
|
|
1914
|
-
|
|
1935
|
+
if not matched_record:
|
|
1936
|
+
probe_summary.append(
|
|
1937
|
+
{
|
|
1938
|
+
"view_id": candidate.view_id,
|
|
1939
|
+
"name": candidate.name,
|
|
1940
|
+
"kind": candidate.kind,
|
|
1941
|
+
"matched_record": False,
|
|
1942
|
+
"writable_field_titles": [],
|
|
1943
|
+
"missing_field_titles": [],
|
|
1944
|
+
"context_complete": True,
|
|
1945
|
+
"selected": False,
|
|
1946
|
+
}
|
|
1947
|
+
)
|
|
1948
|
+
continue
|
|
1915
1949
|
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
context,
|
|
1920
|
-
app_key,
|
|
1921
|
-
candidate,
|
|
1922
|
-
force_refresh=force_refresh_form,
|
|
1923
|
-
)
|
|
1924
|
-
browse_index = cast(FieldIndex, browse_scope["index"])
|
|
1925
|
-
browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"])
|
|
1926
|
-
candidate_titles = [
|
|
1927
|
-
field.que_title
|
|
1928
|
-
for field in self._schema_fields_for_mode(
|
|
1950
|
+
matched_any = True
|
|
1951
|
+
matched_routes.append(candidate)
|
|
1952
|
+
browse_scope = self._build_browse_write_scope(
|
|
1929
1953
|
profile,
|
|
1930
1954
|
context,
|
|
1931
1955
|
app_key,
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
resolved_view=candidate,
|
|
1956
|
+
candidate,
|
|
1957
|
+
force_refresh=effective_force_refresh,
|
|
1935
1958
|
)
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1959
|
+
browse_index = cast(FieldIndex, browse_scope["index"])
|
|
1960
|
+
browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"])
|
|
1961
|
+
candidate_titles = [
|
|
1962
|
+
field.que_title
|
|
1963
|
+
for field in self._schema_fields_for_mode(
|
|
1964
|
+
profile,
|
|
1965
|
+
context,
|
|
1966
|
+
app_key,
|
|
1967
|
+
browse_index,
|
|
1968
|
+
schema_mode="browse",
|
|
1969
|
+
resolved_view=candidate,
|
|
1970
|
+
)
|
|
1971
|
+
if field.que_id in browse_writable_field_ids and field.que_title
|
|
1972
|
+
]
|
|
1973
|
+
plan_data = self._build_record_write_preflight(
|
|
1974
|
+
profile=profile,
|
|
1975
|
+
context=context,
|
|
1976
|
+
operation="update",
|
|
1977
|
+
app_key=app_key,
|
|
1978
|
+
apply_id=record_id,
|
|
1979
|
+
answers=[],
|
|
1980
|
+
fields=fields,
|
|
1981
|
+
force_refresh_form=effective_force_refresh,
|
|
1982
|
+
view_id=candidate.view_id,
|
|
1983
|
+
list_type=None,
|
|
1984
|
+
view_key=None,
|
|
1985
|
+
view_name=None,
|
|
1986
|
+
existing_answers_override=current_answers,
|
|
1987
|
+
)
|
|
1988
|
+
readonly_entries = plan_data.get("validation", {}).get("readonly_or_system_fields", []) if isinstance(plan_data.get("validation"), dict) else []
|
|
1989
|
+
invalid_entries = plan_data.get("validation", {}).get("invalid_fields", []) if isinstance(plan_data.get("validation"), dict) else []
|
|
1990
|
+
missing_field_titles: list[str] = []
|
|
1991
|
+
for entry in readonly_entries:
|
|
1992
|
+
if not isinstance(entry, dict):
|
|
1993
|
+
continue
|
|
1994
|
+
title = _normalize_optional_text(entry.get("que_title"))
|
|
1995
|
+
if title and title not in missing_field_titles:
|
|
1996
|
+
missing_field_titles.append(title)
|
|
1997
|
+
for entry in invalid_entries:
|
|
1998
|
+
if not isinstance(entry, dict):
|
|
1999
|
+
continue
|
|
2000
|
+
if _normalize_optional_text(entry.get("error_code")) != "VIEW_SCOPE_FIELD_HIDDEN":
|
|
2001
|
+
continue
|
|
2002
|
+
field_payload = entry.get("field")
|
|
2003
|
+
if isinstance(field_payload, dict):
|
|
2004
|
+
title = _normalize_optional_text(field_payload.get("que_title"))
|
|
2005
|
+
if title and title not in missing_field_titles:
|
|
2006
|
+
missing_field_titles.append(title)
|
|
2007
|
+
candidate_summary: JSONObject = {
|
|
2008
|
+
"view_id": candidate.view_id,
|
|
2009
|
+
"name": candidate.name,
|
|
2010
|
+
"kind": candidate.kind,
|
|
2011
|
+
"matched_record": True,
|
|
2012
|
+
"writable_field_titles": candidate_titles,
|
|
2013
|
+
"missing_field_titles": missing_field_titles,
|
|
2014
|
+
"context_complete": True,
|
|
2015
|
+
"selected": False,
|
|
2016
|
+
}
|
|
2017
|
+
if plan_data.get("blockers"):
|
|
2018
|
+
confirmation_requests = plan_data.get("confirmation_requests")
|
|
2019
|
+
if (
|
|
2020
|
+
isinstance(confirmation_requests, list)
|
|
2021
|
+
and confirmation_requests
|
|
2022
|
+
and first_confirmation_plan is None
|
|
2023
|
+
):
|
|
2024
|
+
selection = plan_data.get("selection")
|
|
2025
|
+
if not isinstance(selection, dict):
|
|
2026
|
+
selection = {}
|
|
2027
|
+
plan_data["selection"] = selection
|
|
2028
|
+
view_payload = selection.get("view")
|
|
2029
|
+
if not isinstance(view_payload, dict):
|
|
2030
|
+
view_payload = _accessible_view_payload(candidate)
|
|
2031
|
+
selection["view"] = view_payload
|
|
2032
|
+
view_payload["auto_selected"] = True
|
|
2033
|
+
view_payload["selection_source"] = "first_satisfying_accessible_view"
|
|
2034
|
+
candidate_summary["selected"] = True
|
|
2035
|
+
first_confirmation_plan = plan_data
|
|
2036
|
+
elif first_blocked_plan is None:
|
|
2037
|
+
first_blocked_plan = plan_data
|
|
2038
|
+
probe_summary.append(candidate_summary)
|
|
2039
|
+
continue
|
|
2040
|
+
|
|
2041
|
+
selection = plan_data.get("selection")
|
|
2042
|
+
if not isinstance(selection, dict):
|
|
2043
|
+
selection = {}
|
|
2044
|
+
plan_data["selection"] = selection
|
|
2045
|
+
view_payload = selection.get("view")
|
|
2046
|
+
if not isinstance(view_payload, dict):
|
|
2047
|
+
view_payload = _accessible_view_payload(candidate)
|
|
2048
|
+
selection["view"] = view_payload
|
|
2049
|
+
view_payload["auto_selected"] = True
|
|
2050
|
+
view_payload["selection_source"] = "first_satisfying_accessible_view"
|
|
2051
|
+
candidate_summary["selected"] = True
|
|
2052
|
+
probe_summary.append(candidate_summary)
|
|
2053
|
+
plan_data["view_probe_summary"] = probe_summary
|
|
2054
|
+
return {
|
|
2055
|
+
"profile": profile,
|
|
2056
|
+
"ws_id": session_profile.selected_ws_id,
|
|
2057
|
+
"ok": True,
|
|
2058
|
+
"request_route": request_route,
|
|
2059
|
+
"data": plan_data,
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
if not matched_any:
|
|
2063
|
+
blocked_data = self._build_auto_view_blocked_preflight_data(
|
|
2064
|
+
app_key=app_key,
|
|
2065
|
+
record_id=record_id,
|
|
2066
|
+
blockers=["NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"],
|
|
2067
|
+
warnings=[],
|
|
2068
|
+
recommended_next_actions=[
|
|
2069
|
+
"Use record_get or record_list to confirm the record still exists in the current workspace.",
|
|
2070
|
+
"Call record_update_schema_get to inspect whether any accessible view still matches this record.",
|
|
2071
|
+
],
|
|
2072
|
+
view_probe_summary=probe_summary,
|
|
2073
|
+
)
|
|
2074
|
+
return {
|
|
2075
|
+
"profile": profile,
|
|
2076
|
+
"ws_id": session_profile.selected_ws_id,
|
|
2077
|
+
"ok": True,
|
|
2078
|
+
"request_route": request_route,
|
|
2079
|
+
"data": blocked_data,
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
if first_confirmation_plan is not None:
|
|
2083
|
+
first_confirmation_plan["view_probe_summary"] = probe_summary
|
|
2084
|
+
return {
|
|
2085
|
+
"profile": profile,
|
|
2086
|
+
"ws_id": session_profile.selected_ws_id,
|
|
2087
|
+
"ok": True,
|
|
2088
|
+
"request_route": request_route,
|
|
2089
|
+
"data": first_confirmation_plan,
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
union_plan = self._build_record_update_union_preflight(
|
|
1939
2093
|
profile=profile,
|
|
1940
2094
|
context=context,
|
|
1941
|
-
operation="update",
|
|
1942
2095
|
app_key=app_key,
|
|
1943
|
-
|
|
1944
|
-
answers=[],
|
|
2096
|
+
record_id=record_id,
|
|
1945
2097
|
fields=fields,
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
view_key=None,
|
|
1950
|
-
view_name=None,
|
|
1951
|
-
existing_answers_override=current_answers,
|
|
2098
|
+
current_answers=current_answers,
|
|
2099
|
+
matched_routes=matched_routes,
|
|
2100
|
+
force_refresh_form=effective_force_refresh,
|
|
1952
2101
|
)
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
for entry in invalid_entries:
|
|
1963
|
-
if not isinstance(entry, dict):
|
|
1964
|
-
continue
|
|
1965
|
-
if _normalize_optional_text(entry.get("error_code")) != "VIEW_SCOPE_FIELD_HIDDEN":
|
|
1966
|
-
continue
|
|
1967
|
-
field_payload = entry.get("field")
|
|
1968
|
-
if isinstance(field_payload, dict):
|
|
1969
|
-
title = _normalize_optional_text(field_payload.get("que_title"))
|
|
1970
|
-
if title and title not in missing_field_titles:
|
|
1971
|
-
missing_field_titles.append(title)
|
|
1972
|
-
candidate_summary: JSONObject = {
|
|
1973
|
-
"view_id": candidate.view_id,
|
|
1974
|
-
"name": candidate.name,
|
|
1975
|
-
"kind": candidate.kind,
|
|
1976
|
-
"matched_record": True,
|
|
1977
|
-
"writable_field_titles": candidate_titles,
|
|
1978
|
-
"missing_field_titles": missing_field_titles,
|
|
1979
|
-
"context_complete": True,
|
|
1980
|
-
"selected": False,
|
|
1981
|
-
}
|
|
1982
|
-
if plan_data.get("blockers"):
|
|
1983
|
-
confirmation_requests = plan_data.get("confirmation_requests")
|
|
1984
|
-
if (
|
|
1985
|
-
isinstance(confirmation_requests, list)
|
|
1986
|
-
and confirmation_requests
|
|
1987
|
-
and first_confirmation_plan is None
|
|
1988
|
-
):
|
|
1989
|
-
selection = plan_data.get("selection")
|
|
1990
|
-
if not isinstance(selection, dict):
|
|
1991
|
-
selection = {}
|
|
1992
|
-
plan_data["selection"] = selection
|
|
1993
|
-
view_payload = selection.get("view")
|
|
1994
|
-
if not isinstance(view_payload, dict):
|
|
1995
|
-
view_payload = _accessible_view_payload(candidate)
|
|
1996
|
-
selection["view"] = view_payload
|
|
1997
|
-
view_payload["auto_selected"] = True
|
|
1998
|
-
view_payload["selection_source"] = "first_satisfying_accessible_view"
|
|
1999
|
-
candidate_summary["selected"] = True
|
|
2000
|
-
first_confirmation_plan = plan_data
|
|
2001
|
-
elif first_blocked_plan is None:
|
|
2002
|
-
first_blocked_plan = plan_data
|
|
2003
|
-
probe_summary.append(candidate_summary)
|
|
2004
|
-
continue
|
|
2005
|
-
|
|
2006
|
-
selection = plan_data.get("selection")
|
|
2007
|
-
if not isinstance(selection, dict):
|
|
2008
|
-
selection = {}
|
|
2009
|
-
plan_data["selection"] = selection
|
|
2010
|
-
view_payload = selection.get("view")
|
|
2011
|
-
if not isinstance(view_payload, dict):
|
|
2012
|
-
view_payload = _accessible_view_payload(candidate)
|
|
2013
|
-
selection["view"] = view_payload
|
|
2014
|
-
view_payload["auto_selected"] = True
|
|
2015
|
-
view_payload["selection_source"] = "first_satisfying_accessible_view"
|
|
2016
|
-
candidate_summary["selected"] = True
|
|
2017
|
-
probe_summary.append(candidate_summary)
|
|
2018
|
-
plan_data["view_probe_summary"] = probe_summary
|
|
2019
|
-
return {
|
|
2020
|
-
"profile": profile,
|
|
2021
|
-
"ws_id": session_profile.selected_ws_id,
|
|
2022
|
-
"ok": True,
|
|
2023
|
-
"request_route": request_route,
|
|
2024
|
-
"data": plan_data,
|
|
2025
|
-
}
|
|
2102
|
+
if union_plan is not None:
|
|
2103
|
+
union_plan["view_probe_summary"] = probe_summary
|
|
2104
|
+
return {
|
|
2105
|
+
"profile": profile,
|
|
2106
|
+
"ws_id": session_profile.selected_ws_id,
|
|
2107
|
+
"ok": True,
|
|
2108
|
+
"request_route": request_route,
|
|
2109
|
+
"data": union_plan,
|
|
2110
|
+
}
|
|
2026
2111
|
|
|
2027
|
-
if not matched_any:
|
|
2028
2112
|
blocked_data = self._build_auto_view_blocked_preflight_data(
|
|
2029
2113
|
app_key=app_key,
|
|
2030
2114
|
record_id=record_id,
|
|
2031
|
-
blockers=["
|
|
2115
|
+
blockers=["NO_SINGLE_VIEW_CAN_UPDATE_ALL_FIELDS"],
|
|
2032
2116
|
warnings=[],
|
|
2033
2117
|
recommended_next_actions=[
|
|
2034
|
-
"
|
|
2035
|
-
"
|
|
2118
|
+
"Call record_update_schema_get first to inspect the overall writable field set for this record.",
|
|
2119
|
+
"Reduce the update payload until all requested fields fit inside one matched accessible view.",
|
|
2036
2120
|
],
|
|
2037
2121
|
view_probe_summary=probe_summary,
|
|
2122
|
+
template=first_blocked_plan,
|
|
2038
2123
|
)
|
|
2039
2124
|
return {
|
|
2040
2125
|
"profile": profile,
|
|
@@ -2044,37 +2129,180 @@ class RecordTools(ToolBase):
|
|
|
2044
2129
|
"data": blocked_data,
|
|
2045
2130
|
}
|
|
2046
2131
|
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2132
|
+
result = build_once(effective_force_refresh=force_refresh_form)
|
|
2133
|
+
if (
|
|
2134
|
+
not force_refresh_form
|
|
2135
|
+
and self._record_preflight_suggests_stale_schema(cast(JSONObject, result.get("data", {})))
|
|
2136
|
+
):
|
|
2137
|
+
self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
|
|
2138
|
+
result = build_once(effective_force_refresh=True)
|
|
2139
|
+
cast(JSONObject, result.setdefault("data", {}))["schema_force_refreshed"] = True
|
|
2140
|
+
return result
|
|
2141
|
+
|
|
2142
|
+
return self._run_record_tool(profile, runner)
|
|
2143
|
+
|
|
2144
|
+
def _build_record_update_union_preflight(
|
|
2145
|
+
self,
|
|
2146
|
+
*,
|
|
2147
|
+
profile: str,
|
|
2148
|
+
context, # type: ignore[no-untyped-def]
|
|
2149
|
+
app_key: str,
|
|
2150
|
+
record_id: int,
|
|
2151
|
+
fields: JSONObject,
|
|
2152
|
+
current_answers: list[JSONObject],
|
|
2153
|
+
matched_routes: list[AccessibleViewRoute],
|
|
2154
|
+
force_refresh_form: bool,
|
|
2155
|
+
) -> JSONObject | None:
|
|
2156
|
+
if len(matched_routes) < 2:
|
|
2157
|
+
return None
|
|
2158
|
+
|
|
2159
|
+
union_writable_field_ids: set[int] = set()
|
|
2160
|
+
union_visible_question_ids: set[int] = set()
|
|
2161
|
+
matched_view_payloads: list[JSONObject] = []
|
|
2162
|
+
|
|
2163
|
+
for candidate in matched_routes:
|
|
2164
|
+
browse_scope = self._build_browse_write_scope(
|
|
2165
|
+
profile,
|
|
2166
|
+
context,
|
|
2167
|
+
app_key,
|
|
2168
|
+
candidate,
|
|
2169
|
+
force_refresh=force_refresh_form,
|
|
2170
|
+
)
|
|
2171
|
+
union_writable_field_ids.update(cast(set[int], browse_scope["writable_field_ids"]))
|
|
2172
|
+
union_visible_question_ids.update(cast(set[int], browse_scope["visible_question_ids"]))
|
|
2173
|
+
matched_view_payloads.append(_accessible_view_payload(candidate))
|
|
2174
|
+
|
|
2175
|
+
if not union_writable_field_ids and not union_visible_question_ids:
|
|
2176
|
+
return None
|
|
2177
|
+
|
|
2178
|
+
plan_data = self._build_record_write_preflight(
|
|
2179
|
+
profile=profile,
|
|
2180
|
+
context=context,
|
|
2181
|
+
operation="update",
|
|
2182
|
+
app_key=app_key,
|
|
2183
|
+
apply_id=record_id,
|
|
2184
|
+
answers=[],
|
|
2185
|
+
fields=fields,
|
|
2186
|
+
force_refresh_form=force_refresh_form,
|
|
2187
|
+
view_id=None,
|
|
2188
|
+
list_type=None,
|
|
2189
|
+
view_key=None,
|
|
2190
|
+
view_name=None,
|
|
2191
|
+
existing_answers_override=current_answers,
|
|
2192
|
+
)
|
|
2193
|
+
|
|
2194
|
+
schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
|
|
2195
|
+
app_index = _build_applicant_top_level_field_index(schema)
|
|
2196
|
+
validation = cast(JSONObject, plan_data.get("validation", {}))
|
|
2197
|
+
invalid_fields = cast(list[JSONObject], validation.get("invalid_fields", []))
|
|
2198
|
+
missing_required_fields = cast(list[JSONObject], validation.get("missing_required_fields", []))
|
|
2199
|
+
readonly_or_system_fields = cast(list[JSONObject], validation.get("readonly_or_system_fields", []))
|
|
2200
|
+
confirmation_requests = cast(list[JSONObject], plan_data.get("confirmation_requests", []))
|
|
2201
|
+
|
|
2202
|
+
if union_visible_question_ids:
|
|
2203
|
+
invalid_fields.extend(
|
|
2204
|
+
self._validate_view_scoped_subtable_answers(
|
|
2205
|
+
normalized_answers=cast(list[JSONObject], plan_data.get("normalized_answers", [])),
|
|
2206
|
+
full_index=app_index,
|
|
2207
|
+
selector_index=app_index,
|
|
2208
|
+
visible_question_ids=union_visible_question_ids,
|
|
2209
|
+
)
|
|
2210
|
+
)
|
|
2211
|
+
|
|
2212
|
+
existing_readonly_ids = {
|
|
2213
|
+
str(_coerce_count(item.get("que_id")))
|
|
2214
|
+
for item in readonly_or_system_fields
|
|
2215
|
+
if isinstance(item, dict) and _coerce_count(item.get("que_id")) is not None
|
|
2216
|
+
}
|
|
2217
|
+
for entry in cast(list[JSONObject], plan_data.get("resolved_fields", [])):
|
|
2218
|
+
if not isinstance(entry, dict) or not bool(entry.get("resolved")):
|
|
2219
|
+
continue
|
|
2220
|
+
que_id = _coerce_count(entry.get("que_id"))
|
|
2221
|
+
if que_id is None or que_id in union_writable_field_ids or str(que_id) in existing_readonly_ids:
|
|
2222
|
+
continue
|
|
2223
|
+
readonly_or_system_fields.append(
|
|
2224
|
+
{
|
|
2225
|
+
"que_id": que_id,
|
|
2226
|
+
"que_title": entry.get("que_title"),
|
|
2227
|
+
"que_type": entry.get("que_type"),
|
|
2228
|
+
"readonly": entry.get("readonly"),
|
|
2229
|
+
"system": entry.get("system"),
|
|
2230
|
+
"source": entry.get("source"),
|
|
2231
|
+
"requested": entry.get("requested"),
|
|
2232
|
+
"reason_code": "view_readonly",
|
|
2055
2233
|
}
|
|
2234
|
+
)
|
|
2056
2235
|
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2236
|
+
warnings_payload = validation.get("warnings")
|
|
2237
|
+
warnings = list(warnings_payload) if isinstance(warnings_payload, list) else []
|
|
2238
|
+
union_warning = "update preflight used the union of matched accessible views because no single matched view covered the full payload."
|
|
2239
|
+
if union_warning not in warnings:
|
|
2240
|
+
warnings.append(union_warning)
|
|
2241
|
+
|
|
2242
|
+
blockers: list[str] = []
|
|
2243
|
+
if invalid_fields:
|
|
2244
|
+
blockers.append("payload contains invalid field values")
|
|
2245
|
+
if missing_required_fields:
|
|
2246
|
+
blockers.append("required fields are missing")
|
|
2247
|
+
if readonly_or_system_fields:
|
|
2248
|
+
blockers.append("payload writes fields that are not writable in any matched accessible view")
|
|
2249
|
+
if confirmation_requests:
|
|
2250
|
+
blockers.append("one or more lookup fields require confirmation before the write can execute")
|
|
2251
|
+
|
|
2252
|
+
validation["valid"] = (
|
|
2253
|
+
not invalid_fields
|
|
2254
|
+
and not missing_required_fields
|
|
2255
|
+
and not readonly_or_system_fields
|
|
2256
|
+
and not confirmation_requests
|
|
2257
|
+
)
|
|
2258
|
+
validation["invalid_fields"] = invalid_fields
|
|
2259
|
+
validation["missing_required_fields"] = missing_required_fields
|
|
2260
|
+
validation["readonly_or_system_fields"] = readonly_or_system_fields
|
|
2261
|
+
validation["warnings"] = warnings
|
|
2262
|
+
plan_data["validation"] = validation
|
|
2263
|
+
plan_data["field_errors"] = self._record_write_field_errors(
|
|
2264
|
+
invalid_fields=invalid_fields,
|
|
2265
|
+
missing_required_fields=missing_required_fields,
|
|
2266
|
+
readonly_or_system_fields=readonly_or_system_fields,
|
|
2267
|
+
)
|
|
2268
|
+
plan_data["blockers"] = blockers
|
|
2269
|
+
|
|
2270
|
+
recommended_next_actions = (
|
|
2271
|
+
list(plan_data.get("recommended_next_actions"))
|
|
2272
|
+
if isinstance(plan_data.get("recommended_next_actions"), list)
|
|
2273
|
+
else []
|
|
2274
|
+
)
|
|
2275
|
+
if readonly_or_system_fields:
|
|
2276
|
+
recommended_next_actions.append(
|
|
2277
|
+
"Remove fields that are not writable in any matched accessible view for this record."
|
|
2068
2278
|
)
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2279
|
+
if invalid_fields:
|
|
2280
|
+
recommended_next_actions.append("Fix invalid_fields before applying the write.")
|
|
2281
|
+
if missing_required_fields:
|
|
2282
|
+
recommended_next_actions.append("Fill missing required fields before applying the write.")
|
|
2283
|
+
if confirmation_requests:
|
|
2284
|
+
recommended_next_actions.append(
|
|
2285
|
+
"Review confirmation_requests and retry with explicit ids/objects for the ambiguous lookup fields."
|
|
2286
|
+
)
|
|
2287
|
+
plan_data["recommended_next_actions"] = list(dict.fromkeys(str(item) for item in recommended_next_actions))
|
|
2288
|
+
|
|
2289
|
+
selection = plan_data.get("selection")
|
|
2290
|
+
if not isinstance(selection, dict):
|
|
2291
|
+
selection = {}
|
|
2292
|
+
selection["view"] = {
|
|
2293
|
+
"view_id": "virtual:matched_view_union",
|
|
2294
|
+
"name": "Matched accessible views (union)",
|
|
2295
|
+
"kind": "virtual",
|
|
2296
|
+
"analysis_supported": False,
|
|
2297
|
+
"auto_selected": True,
|
|
2298
|
+
"selection_source": "matched_accessible_view_union",
|
|
2299
|
+
"matched_views": matched_view_payloads,
|
|
2300
|
+
}
|
|
2301
|
+
plan_data["selection"] = selection
|
|
2076
2302
|
|
|
2077
|
-
|
|
2303
|
+
if invalid_fields or missing_required_fields or readonly_or_system_fields:
|
|
2304
|
+
return None
|
|
2305
|
+
return plan_data
|
|
2078
2306
|
|
|
2079
2307
|
def _build_auto_view_blocked_preflight_data(
|
|
2080
2308
|
self,
|
|
@@ -2278,6 +2506,7 @@ class RecordTools(ToolBase):
|
|
|
2278
2506
|
view_key=view_key,
|
|
2279
2507
|
view_name=view_name,
|
|
2280
2508
|
)
|
|
2509
|
+
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
2281
2510
|
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
2282
2511
|
normalized_payload: JSONObject = self._record_write_normalized_payload(
|
|
2283
2512
|
operation="insert",
|
|
@@ -2304,7 +2533,7 @@ class RecordTools(ToolBase):
|
|
|
2304
2533
|
fields={},
|
|
2305
2534
|
submit_type=submit_type_value,
|
|
2306
2535
|
verify_write=verify_write,
|
|
2307
|
-
force_refresh_form=
|
|
2536
|
+
force_refresh_form=preflight_used_force_refresh,
|
|
2308
2537
|
)
|
|
2309
2538
|
except QingflowApiError as exc:
|
|
2310
2539
|
self._raise_record_write_permission_error(
|
|
@@ -2351,6 +2580,7 @@ class RecordTools(ToolBase):
|
|
|
2351
2580
|
view_key=view_key,
|
|
2352
2581
|
view_name=view_name,
|
|
2353
2582
|
)
|
|
2583
|
+
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
2354
2584
|
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
2355
2585
|
normalized_payload = self._record_write_normalized_payload(
|
|
2356
2586
|
operation="update",
|
|
@@ -2378,7 +2608,7 @@ class RecordTools(ToolBase):
|
|
|
2378
2608
|
fields={},
|
|
2379
2609
|
role=1,
|
|
2380
2610
|
verify_write=verify_write,
|
|
2381
|
-
force_refresh_form=
|
|
2611
|
+
force_refresh_form=preflight_used_force_refresh,
|
|
2382
2612
|
)
|
|
2383
2613
|
except QingflowApiError as exc:
|
|
2384
2614
|
self._raise_record_write_permission_error(
|
|
@@ -4452,6 +4682,23 @@ class RecordTools(ToolBase):
|
|
|
4452
4682
|
view_key=view_key,
|
|
4453
4683
|
view_name=view_name,
|
|
4454
4684
|
)
|
|
4685
|
+
if not force_refresh_form and self._record_preflight_suggests_stale_schema(plan_data):
|
|
4686
|
+
self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
|
|
4687
|
+
plan_data = self._build_record_write_preflight(
|
|
4688
|
+
profile=profile,
|
|
4689
|
+
context=context,
|
|
4690
|
+
operation=operation,
|
|
4691
|
+
app_key=app_key,
|
|
4692
|
+
apply_id=apply_id,
|
|
4693
|
+
answers=answers,
|
|
4694
|
+
fields=fields,
|
|
4695
|
+
force_refresh_form=True,
|
|
4696
|
+
view_id=view_id,
|
|
4697
|
+
list_type=list_type,
|
|
4698
|
+
view_key=view_key,
|
|
4699
|
+
view_name=view_name,
|
|
4700
|
+
)
|
|
4701
|
+
plan_data["schema_force_refreshed"] = True
|
|
4455
4702
|
return {
|
|
4456
4703
|
"profile": profile,
|
|
4457
4704
|
"ws_id": session_profile.selected_ws_id,
|
|
@@ -5777,6 +6024,84 @@ class RecordTools(ToolBase):
|
|
|
5777
6024
|
self._view_config_cache[cache_key] = normalized
|
|
5778
6025
|
return normalized
|
|
5779
6026
|
|
|
6027
|
+
def _clear_record_schema_caches(
|
|
6028
|
+
self,
|
|
6029
|
+
*,
|
|
6030
|
+
profile: str,
|
|
6031
|
+
app_key: str,
|
|
6032
|
+
resolved_view: AccessibleViewRoute | None = None,
|
|
6033
|
+
clear_view_caches: bool = False,
|
|
6034
|
+
) -> None:
|
|
6035
|
+
view_key = (
|
|
6036
|
+
resolved_view.view_selection.view_key
|
|
6037
|
+
if resolved_view is not None and resolved_view.view_selection is not None
|
|
6038
|
+
else None
|
|
6039
|
+
)
|
|
6040
|
+
for cache_key in list(self._form_cache.keys()):
|
|
6041
|
+
cache_profile, cache_scope, *_ = cache_key
|
|
6042
|
+
if cache_profile != profile:
|
|
6043
|
+
continue
|
|
6044
|
+
if cache_scope == app_key:
|
|
6045
|
+
self._form_cache.pop(cache_key, None)
|
|
6046
|
+
continue
|
|
6047
|
+
if view_key is not None and cache_scope == f"view:{view_key}":
|
|
6048
|
+
self._form_cache.pop(cache_key, None)
|
|
6049
|
+
continue
|
|
6050
|
+
if clear_view_caches and isinstance(cache_scope, str) and cache_scope.startswith("view:"):
|
|
6051
|
+
self._form_cache.pop(cache_key, None)
|
|
6052
|
+
self._applicant_node_cache.pop((profile, app_key), None)
|
|
6053
|
+
self._view_list_cache.pop((profile, app_key), None)
|
|
6054
|
+
if view_key is not None:
|
|
6055
|
+
self._view_config_cache.pop((profile, view_key), None)
|
|
6056
|
+
elif clear_view_caches:
|
|
6057
|
+
for cache_key in [item for item in self._view_config_cache.keys() if item[0] == profile]:
|
|
6058
|
+
self._view_config_cache.pop(cache_key, None)
|
|
6059
|
+
|
|
6060
|
+
def _record_preflight_used_force_refresh(self, raw_preflight: JSONObject) -> bool:
|
|
6061
|
+
data = raw_preflight.get("data")
|
|
6062
|
+
return bool(isinstance(data, dict) and data.get("schema_force_refreshed"))
|
|
6063
|
+
|
|
6064
|
+
def _record_preflight_suggests_stale_schema(self, plan_data: JSONObject) -> bool:
|
|
6065
|
+
validation = plan_data.get("validation")
|
|
6066
|
+
invalid_fields = validation.get("invalid_fields") if isinstance(validation, dict) else None
|
|
6067
|
+
if not isinstance(invalid_fields, list):
|
|
6068
|
+
return False
|
|
6069
|
+
return any(self._field_error_suggests_stale_schema(entry) for entry in invalid_fields if isinstance(entry, dict))
|
|
6070
|
+
|
|
6071
|
+
def _field_error_suggests_stale_schema(self, entry: JSONObject) -> bool:
|
|
6072
|
+
error_code = _normalize_optional_text(entry.get("error_code"))
|
|
6073
|
+
if error_code == "VIEW_SCOPE_FIELD_HIDDEN":
|
|
6074
|
+
return False
|
|
6075
|
+
if error_code == "FIELD_NOT_FOUND":
|
|
6076
|
+
return True
|
|
6077
|
+
expected_format = entry.get("expected_format")
|
|
6078
|
+
if isinstance(expected_format, dict) and _normalize_optional_text(expected_format.get("kind")) == "subtable_rows":
|
|
6079
|
+
return True
|
|
6080
|
+
location = _normalize_optional_text(entry.get("location"))
|
|
6081
|
+
return bool(location and "[" in location and "." in location)
|
|
6082
|
+
|
|
6083
|
+
def _record_get_needs_schema_refresh(
|
|
6084
|
+
self,
|
|
6085
|
+
*,
|
|
6086
|
+
answer_list: list[JSONValue],
|
|
6087
|
+
selected_fields: list[FormField],
|
|
6088
|
+
record: JSONObject,
|
|
6089
|
+
normalized_record: JSONObject,
|
|
6090
|
+
) -> bool:
|
|
6091
|
+
for field in selected_fields:
|
|
6092
|
+
if field.que_type not in SUBTABLE_QUE_TYPES:
|
|
6093
|
+
continue
|
|
6094
|
+
answer = _find_answer_for_field(answer_list, field)
|
|
6095
|
+
if not isinstance(answer, dict):
|
|
6096
|
+
continue
|
|
6097
|
+
if not _subtable_answer_has_unmapped_cells(answer, field):
|
|
6098
|
+
continue
|
|
6099
|
+
if _subtable_output_effectively_empty(record.get(field.que_title)) or _subtable_output_effectively_empty(
|
|
6100
|
+
normalized_record.get(field.que_title)
|
|
6101
|
+
):
|
|
6102
|
+
return True
|
|
6103
|
+
return False
|
|
6104
|
+
|
|
5780
6105
|
def _get_system_browse_schema(
|
|
5781
6106
|
self,
|
|
5782
6107
|
profile: str,
|
|
@@ -9943,6 +10268,45 @@ def _normalize_subtable_answer_value_for_output(answer: JSONObject, field: FormF
|
|
|
9943
10268
|
return normalized_rows or None
|
|
9944
10269
|
|
|
9945
10270
|
|
|
10271
|
+
def _subtable_answer_has_unmapped_cells(answer: JSONObject, field: FormField) -> bool:
|
|
10272
|
+
table_values = answer.get("tableValues")
|
|
10273
|
+
if not isinstance(table_values, list) or not table_values:
|
|
10274
|
+
return False
|
|
10275
|
+
subtable_column_ids = {
|
|
10276
|
+
cast(int, item["que_id"])
|
|
10277
|
+
for item in _subtable_columns_for_field(field)
|
|
10278
|
+
if isinstance(item.get("que_id"), int)
|
|
10279
|
+
}
|
|
10280
|
+
if not subtable_column_ids:
|
|
10281
|
+
return False
|
|
10282
|
+
for raw_row in table_values:
|
|
10283
|
+
if not isinstance(raw_row, list):
|
|
10284
|
+
continue
|
|
10285
|
+
for cell in raw_row:
|
|
10286
|
+
if not isinstance(cell, dict):
|
|
10287
|
+
continue
|
|
10288
|
+
que_id = _coerce_count(cell.get("queId"))
|
|
10289
|
+
if que_id is None or que_id in subtable_column_ids:
|
|
10290
|
+
continue
|
|
10291
|
+
if _answer_has_meaningful_content(cell):
|
|
10292
|
+
return True
|
|
10293
|
+
return False
|
|
10294
|
+
|
|
10295
|
+
|
|
10296
|
+
def _subtable_output_effectively_empty(value: JSONValue) -> bool:
|
|
10297
|
+
if value in (None, "", [], {}):
|
|
10298
|
+
return True
|
|
10299
|
+
if isinstance(value, dict):
|
|
10300
|
+
rows = value.get("rows")
|
|
10301
|
+
if not isinstance(rows, list) or not rows:
|
|
10302
|
+
return True
|
|
10303
|
+
return all(
|
|
10304
|
+
isinstance(row, dict) and set(row.keys()) <= {"__row_id__"}
|
|
10305
|
+
for row in rows
|
|
10306
|
+
)
|
|
10307
|
+
return False
|
|
10308
|
+
|
|
10309
|
+
|
|
9946
10310
|
def _canonicalize_answer_value_for_compare(answer: JSONObject | None, field: FormField | None) -> JSONValue:
|
|
9947
10311
|
if answer is None or field is None:
|
|
9948
10312
|
return None
|