@josephyan/qingflow-cli 0.2.0-beta.55
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 +30 -0
- package/docs/local-agent-install.md +235 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow.mjs +5 -0
- package/npm/lib/runtime.mjs +204 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow +15 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +547 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +985 -0
- package/src/qingflow_mcp/builder_facade/service.py +8243 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +78 -0
- package/src/qingflow_mcp/cli/commands/builder.py +184 -0
- package/src/qingflow_mcp/cli/commands/common.py +47 -0
- package/src/qingflow_mcp/cli/commands/imports.py +86 -0
- package/src/qingflow_mcp/cli/commands/record.py +202 -0
- package/src/qingflow_mcp/cli/commands/task.py +87 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
- package/src/qingflow_mcp/cli/context.py +48 -0
- package/src/qingflow_mcp/cli/formatters.py +269 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +147 -0
- package/src/qingflow_mcp/config.py +221 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/server.py +211 -0
- package/src/qingflow_mcp/server_app_builder.py +387 -0
- package/src/qingflow_mcp/server_app_user.py +317 -0
- package/src/qingflow_mcp/session_store.py +289 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2339 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +853 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
- package/src/qingflow_mcp/tools/app_tools.py +850 -0
- package/src/qingflow_mcp/tools/approval_tools.py +833 -0
- package/src/qingflow_mcp/tools/auth_tools.py +697 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
- package/src/qingflow_mcp/tools/directory_tools.py +648 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +385 -0
- package/src/qingflow_mcp/tools/import_tools.py +1971 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +240 -0
- package/src/qingflow_mcp/tools/portal_tools.py +131 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
- package/src/qingflow_mcp/tools/record_tools.py +12739 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
- package/src/qingflow_mcp/tools/task_tools.py +843 -0
- package/src/qingflow_mcp/tools/view_tools.py +280 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import AliasChoices, Field, model_validator
|
|
7
|
+
|
|
8
|
+
from ..solution.spec_models import StrictModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PublicFieldType(str, Enum):
|
|
12
|
+
text = "text"
|
|
13
|
+
long_text = "long_text"
|
|
14
|
+
number = "number"
|
|
15
|
+
amount = "amount"
|
|
16
|
+
date = "date"
|
|
17
|
+
datetime = "datetime"
|
|
18
|
+
member = "member"
|
|
19
|
+
department = "department"
|
|
20
|
+
single_select = "single_select"
|
|
21
|
+
multi_select = "multi_select"
|
|
22
|
+
phone = "phone"
|
|
23
|
+
email = "email"
|
|
24
|
+
address = "address"
|
|
25
|
+
attachment = "attachment"
|
|
26
|
+
boolean = "boolean"
|
|
27
|
+
relation = "relation"
|
|
28
|
+
subtable = "subtable"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PublicRelationMode(str, Enum):
|
|
32
|
+
single = "single"
|
|
33
|
+
multiple = "multiple"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
|
|
37
|
+
"textarea": PublicFieldType.long_text,
|
|
38
|
+
"amount": PublicFieldType.amount,
|
|
39
|
+
"currency": PublicFieldType.amount,
|
|
40
|
+
"mobile": PublicFieldType.phone,
|
|
41
|
+
"user": PublicFieldType.member,
|
|
42
|
+
"users": PublicFieldType.member,
|
|
43
|
+
"select": PublicFieldType.single_select,
|
|
44
|
+
"radio": PublicFieldType.single_select,
|
|
45
|
+
"checkbox": PublicFieldType.multi_select,
|
|
46
|
+
"multi_select": PublicFieldType.multi_select,
|
|
47
|
+
"multi-select": PublicFieldType.multi_select,
|
|
48
|
+
"departments": PublicFieldType.department,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
FIELD_TYPE_ID_ALIASES: dict[int, PublicFieldType] = {
|
|
52
|
+
2: PublicFieldType.text,
|
|
53
|
+
3: PublicFieldType.long_text,
|
|
54
|
+
4: PublicFieldType.date,
|
|
55
|
+
5: PublicFieldType.member,
|
|
56
|
+
6: PublicFieldType.email,
|
|
57
|
+
7: PublicFieldType.phone,
|
|
58
|
+
8: PublicFieldType.number,
|
|
59
|
+
10: PublicFieldType.boolean,
|
|
60
|
+
11: PublicFieldType.single_select,
|
|
61
|
+
12: PublicFieldType.multi_select,
|
|
62
|
+
13: PublicFieldType.attachment,
|
|
63
|
+
18: PublicFieldType.subtable,
|
|
64
|
+
21: PublicFieldType.address,
|
|
65
|
+
22: PublicFieldType.department,
|
|
66
|
+
25: PublicFieldType.relation,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PublicViewType(str, Enum):
|
|
71
|
+
table = "table"
|
|
72
|
+
card = "card"
|
|
73
|
+
board = "board"
|
|
74
|
+
gantt = "gantt"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PublicChartType(str, Enum):
|
|
78
|
+
target = "target"
|
|
79
|
+
pie = "pie"
|
|
80
|
+
bar = "bar"
|
|
81
|
+
line = "line"
|
|
82
|
+
table = "table"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LayoutApplyMode(str, Enum):
|
|
86
|
+
merge = "merge"
|
|
87
|
+
replace = "replace"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class LayoutPreset(str, Enum):
|
|
91
|
+
balanced = "balanced"
|
|
92
|
+
compact = "compact"
|
|
93
|
+
single_section = "single_section"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FlowPreset(str, Enum):
|
|
97
|
+
basic_approval = "basic_approval"
|
|
98
|
+
basic_fill_then_approve = "basic_fill_then_approve"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ViewsPreset(str, Enum):
|
|
102
|
+
default_table = "default_table"
|
|
103
|
+
status_board = "status_board"
|
|
104
|
+
default_gantt = "default_gantt"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PublicFlowNodeType(str, Enum):
|
|
108
|
+
start = "start"
|
|
109
|
+
approve = "approve"
|
|
110
|
+
fill = "fill"
|
|
111
|
+
copy = "copy"
|
|
112
|
+
branch = "branch"
|
|
113
|
+
condition = "condition"
|
|
114
|
+
webhook = "webhook"
|
|
115
|
+
end = "end"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class FlowConditionOperator(str, Enum):
|
|
119
|
+
eq = "eq"
|
|
120
|
+
neq = "neq"
|
|
121
|
+
in_ = "in"
|
|
122
|
+
contains = "contains"
|
|
123
|
+
gte = "gte"
|
|
124
|
+
lte = "lte"
|
|
125
|
+
is_empty = "is_empty"
|
|
126
|
+
not_empty = "not_empty"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ViewFilterOperator(str, Enum):
|
|
130
|
+
eq = "eq"
|
|
131
|
+
neq = "neq"
|
|
132
|
+
in_ = "in"
|
|
133
|
+
contains = "contains"
|
|
134
|
+
gte = "gte"
|
|
135
|
+
lte = "lte"
|
|
136
|
+
is_empty = "is_empty"
|
|
137
|
+
not_empty = "not_empty"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class FlowAssigneePatch(StrictModel):
|
|
141
|
+
role_ids: list[int] = Field(default_factory=list)
|
|
142
|
+
role_names: list[str] = Field(default_factory=list)
|
|
143
|
+
member_uids: list[int] = Field(default_factory=list)
|
|
144
|
+
member_emails: list[str] = Field(default_factory=list)
|
|
145
|
+
member_names: list[str] = Field(default_factory=list)
|
|
146
|
+
include_sub_departs: bool | None = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class FlowNodePermissionsPatch(StrictModel):
|
|
150
|
+
editable_fields: list[str] = Field(default_factory=list)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class FlowConditionRulePatch(StrictModel):
|
|
154
|
+
field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
|
|
155
|
+
operator: FlowConditionOperator = Field(validation_alias=AliasChoices("operator", "op"))
|
|
156
|
+
values: list[Any] = Field(default_factory=list)
|
|
157
|
+
|
|
158
|
+
@model_validator(mode="before")
|
|
159
|
+
@classmethod
|
|
160
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
161
|
+
if not isinstance(value, dict):
|
|
162
|
+
return value
|
|
163
|
+
payload = dict(value)
|
|
164
|
+
if "value" in payload and "values" not in payload:
|
|
165
|
+
payload["values"] = [payload.pop("value")]
|
|
166
|
+
raw_operator = payload.get("operator", payload.get("op"))
|
|
167
|
+
if isinstance(raw_operator, str):
|
|
168
|
+
normalized = raw_operator.strip().lower()
|
|
169
|
+
operator_aliases = {
|
|
170
|
+
"equals": FlowConditionOperator.eq.value,
|
|
171
|
+
"equal": FlowConditionOperator.eq.value,
|
|
172
|
+
"=": FlowConditionOperator.eq.value,
|
|
173
|
+
"not_equals": FlowConditionOperator.neq.value,
|
|
174
|
+
"not_equal": FlowConditionOperator.neq.value,
|
|
175
|
+
"!=": FlowConditionOperator.neq.value,
|
|
176
|
+
">=": FlowConditionOperator.gte.value,
|
|
177
|
+
"<=": FlowConditionOperator.lte.value,
|
|
178
|
+
"any_of": FlowConditionOperator.in_.value,
|
|
179
|
+
"one_of": FlowConditionOperator.in_.value,
|
|
180
|
+
"between_any": FlowConditionOperator.in_.value,
|
|
181
|
+
"empty": FlowConditionOperator.is_empty.value,
|
|
182
|
+
"is blank": FlowConditionOperator.is_empty.value,
|
|
183
|
+
"blank": FlowConditionOperator.is_empty.value,
|
|
184
|
+
"not_empty": FlowConditionOperator.not_empty.value,
|
|
185
|
+
"not blank": FlowConditionOperator.not_empty.value,
|
|
186
|
+
}
|
|
187
|
+
if normalized in operator_aliases:
|
|
188
|
+
payload["operator"] = operator_aliases[normalized]
|
|
189
|
+
elif "operator" not in payload:
|
|
190
|
+
payload["operator"] = normalized
|
|
191
|
+
payload.pop("op", None)
|
|
192
|
+
return payload
|
|
193
|
+
|
|
194
|
+
@model_validator(mode="after")
|
|
195
|
+
def validate_shape(self) -> "FlowConditionRulePatch":
|
|
196
|
+
if self.operator in {FlowConditionOperator.is_empty, FlowConditionOperator.not_empty}:
|
|
197
|
+
self.values = []
|
|
198
|
+
return self
|
|
199
|
+
if not self.values:
|
|
200
|
+
raise ValueError("condition rule requires values")
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ViewFilterRulePatch(StrictModel):
|
|
205
|
+
field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
|
|
206
|
+
operator: ViewFilterOperator = Field(validation_alias=AliasChoices("operator", "op"))
|
|
207
|
+
values: list[Any] = Field(default_factory=list)
|
|
208
|
+
|
|
209
|
+
@model_validator(mode="before")
|
|
210
|
+
@classmethod
|
|
211
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
212
|
+
if not isinstance(value, dict):
|
|
213
|
+
return value
|
|
214
|
+
payload = dict(value)
|
|
215
|
+
if "value" in payload and "values" not in payload:
|
|
216
|
+
payload["values"] = [payload.pop("value")]
|
|
217
|
+
raw_operator = payload.get("operator", payload.get("op"))
|
|
218
|
+
if isinstance(raw_operator, str):
|
|
219
|
+
normalized = raw_operator.strip().lower()
|
|
220
|
+
operator_aliases = {
|
|
221
|
+
"equals": ViewFilterOperator.eq.value,
|
|
222
|
+
"equal": ViewFilterOperator.eq.value,
|
|
223
|
+
"=": ViewFilterOperator.eq.value,
|
|
224
|
+
"not_equals": ViewFilterOperator.neq.value,
|
|
225
|
+
"not_equal": ViewFilterOperator.neq.value,
|
|
226
|
+
"!=": ViewFilterOperator.neq.value,
|
|
227
|
+
">=": ViewFilterOperator.gte.value,
|
|
228
|
+
"<=": ViewFilterOperator.lte.value,
|
|
229
|
+
"any_of": ViewFilterOperator.in_.value,
|
|
230
|
+
"one_of": ViewFilterOperator.in_.value,
|
|
231
|
+
"between_any": ViewFilterOperator.in_.value,
|
|
232
|
+
"empty": ViewFilterOperator.is_empty.value,
|
|
233
|
+
"is blank": ViewFilterOperator.is_empty.value,
|
|
234
|
+
"blank": ViewFilterOperator.is_empty.value,
|
|
235
|
+
"not_empty": ViewFilterOperator.not_empty.value,
|
|
236
|
+
"not blank": ViewFilterOperator.not_empty.value,
|
|
237
|
+
}
|
|
238
|
+
if normalized in operator_aliases:
|
|
239
|
+
payload["operator"] = operator_aliases[normalized]
|
|
240
|
+
elif "operator" not in payload:
|
|
241
|
+
payload["operator"] = normalized
|
|
242
|
+
payload.pop("op", None)
|
|
243
|
+
return payload
|
|
244
|
+
|
|
245
|
+
@model_validator(mode="after")
|
|
246
|
+
def validate_shape(self) -> "ViewFilterRulePatch":
|
|
247
|
+
if self.operator in {ViewFilterOperator.is_empty, ViewFilterOperator.not_empty}:
|
|
248
|
+
self.values = []
|
|
249
|
+
return self
|
|
250
|
+
if not self.values:
|
|
251
|
+
raise ValueError("view filter rule requires values")
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class FieldSelector(StrictModel):
|
|
256
|
+
field_id: str | None = None
|
|
257
|
+
que_id: int | None = None
|
|
258
|
+
name: str | None = Field(default=None, validation_alias=AliasChoices("name", "title", "label"))
|
|
259
|
+
|
|
260
|
+
@model_validator(mode="after")
|
|
261
|
+
def validate_selector(self) -> "FieldSelector":
|
|
262
|
+
if not any((self.field_id, self.que_id, self.name)):
|
|
263
|
+
raise ValueError("selector must include field_id, que_id, or name")
|
|
264
|
+
return self
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class FieldPatch(StrictModel):
|
|
268
|
+
name: str = Field(validation_alias=AliasChoices("name", "title", "label"))
|
|
269
|
+
type: PublicFieldType
|
|
270
|
+
required: bool = False
|
|
271
|
+
description: str | None = None
|
|
272
|
+
options: list[str] = Field(default_factory=list)
|
|
273
|
+
target_app_key: str | None = None
|
|
274
|
+
display_field: FieldSelector | None = None
|
|
275
|
+
visible_fields: list[FieldSelector] = Field(default_factory=list)
|
|
276
|
+
relation_mode: PublicRelationMode | None = Field(default=None, validation_alias=AliasChoices("relation_mode", "relationMode", "selection_mode", "selectionMode"))
|
|
277
|
+
subfields: list["FieldPatch"] = Field(default_factory=list)
|
|
278
|
+
|
|
279
|
+
@model_validator(mode="after")
|
|
280
|
+
def validate_shape(self) -> "FieldPatch":
|
|
281
|
+
if self.type == PublicFieldType.relation and not self.target_app_key:
|
|
282
|
+
raise ValueError("relation field requires target_app_key")
|
|
283
|
+
if self.type != PublicFieldType.relation and self.target_app_key:
|
|
284
|
+
raise ValueError("target_app_key is only allowed for relation fields")
|
|
285
|
+
if self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields or self.relation_mode is not None):
|
|
286
|
+
raise ValueError("display_field, visible_fields, and relation_mode are only allowed for relation fields")
|
|
287
|
+
if self.type == PublicFieldType.subtable and not self.subfields:
|
|
288
|
+
raise ValueError("subtable field requires subfields")
|
|
289
|
+
if self.type != PublicFieldType.subtable and self.subfields:
|
|
290
|
+
raise ValueError("subfields are only allowed for subtable fields")
|
|
291
|
+
return self
|
|
292
|
+
|
|
293
|
+
@model_validator(mode="before")
|
|
294
|
+
@classmethod
|
|
295
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
296
|
+
return _normalize_field_payload(value)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class FieldMutation(StrictModel):
|
|
300
|
+
name: str | None = Field(default=None, validation_alias=AliasChoices("name", "title", "label"))
|
|
301
|
+
type: PublicFieldType | None = None
|
|
302
|
+
required: bool | None = None
|
|
303
|
+
description: str | None = None
|
|
304
|
+
options: list[str] | None = None
|
|
305
|
+
target_app_key: str | None = None
|
|
306
|
+
display_field: FieldSelector | None = None
|
|
307
|
+
visible_fields: list[FieldSelector] | None = None
|
|
308
|
+
relation_mode: PublicRelationMode | None = Field(default=None, validation_alias=AliasChoices("relation_mode", "relationMode", "selection_mode", "selectionMode"))
|
|
309
|
+
subfields: list[FieldPatch] | None = None
|
|
310
|
+
|
|
311
|
+
@model_validator(mode="after")
|
|
312
|
+
def validate_shape(self) -> "FieldMutation":
|
|
313
|
+
if self.type == PublicFieldType.relation and not self.target_app_key:
|
|
314
|
+
raise ValueError("relation field requires target_app_key")
|
|
315
|
+
if self.type is not None and self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields or self.relation_mode is not None):
|
|
316
|
+
raise ValueError("display_field, visible_fields, and relation_mode are only allowed for relation fields")
|
|
317
|
+
if self.type == PublicFieldType.subtable and not self.subfields:
|
|
318
|
+
raise ValueError("subtable field requires subfields")
|
|
319
|
+
return self
|
|
320
|
+
|
|
321
|
+
@model_validator(mode="before")
|
|
322
|
+
@classmethod
|
|
323
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
324
|
+
return _normalize_field_payload(value)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class FieldUpdatePatch(StrictModel):
|
|
328
|
+
selector: FieldSelector
|
|
329
|
+
set: FieldMutation
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class FieldRemovePatch(StrictModel):
|
|
333
|
+
field_id: str | None = None
|
|
334
|
+
que_id: int | None = None
|
|
335
|
+
name: str | None = Field(default=None, validation_alias=AliasChoices("name", "title", "label"))
|
|
336
|
+
|
|
337
|
+
@model_validator(mode="after")
|
|
338
|
+
def validate_shape(self) -> "FieldRemovePatch":
|
|
339
|
+
if not any((self.field_id, self.que_id, self.name)):
|
|
340
|
+
raise ValueError("remove patch must include field_id, que_id, or name")
|
|
341
|
+
return self
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _coerce_layout_columns(value: Any) -> int | None:
|
|
345
|
+
if isinstance(value, bool):
|
|
346
|
+
return None
|
|
347
|
+
if isinstance(value, int):
|
|
348
|
+
return value if value > 0 else None
|
|
349
|
+
if isinstance(value, str):
|
|
350
|
+
stripped = value.strip()
|
|
351
|
+
if stripped.isdigit():
|
|
352
|
+
parsed = int(stripped)
|
|
353
|
+
return parsed if parsed > 0 else None
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _normalize_layout_rows(value: Any, *, columns: int | None = None) -> Any:
|
|
358
|
+
if not isinstance(value, list):
|
|
359
|
+
return value
|
|
360
|
+
if value and all(isinstance(item, list) for item in value):
|
|
361
|
+
return value
|
|
362
|
+
if not value:
|
|
363
|
+
return []
|
|
364
|
+
width = columns if columns and columns > 0 else None
|
|
365
|
+
if width is None:
|
|
366
|
+
return [list(value)]
|
|
367
|
+
return [list(value[index : index + width]) for index in range(0, len(value), width) if value[index : index + width]]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class LayoutSectionPatch(StrictModel):
|
|
371
|
+
section_id: str | None = Field(default=None, validation_alias=AliasChoices("section_id", "sectionId"))
|
|
372
|
+
title: str
|
|
373
|
+
rows: list[list[Any]] = Field(default_factory=list)
|
|
374
|
+
|
|
375
|
+
@model_validator(mode="before")
|
|
376
|
+
@classmethod
|
|
377
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
378
|
+
if not isinstance(value, dict):
|
|
379
|
+
return value
|
|
380
|
+
payload = dict(value)
|
|
381
|
+
if "name" in payload and "title" not in payload:
|
|
382
|
+
payload["title"] = payload.pop("name")
|
|
383
|
+
shorthand: Any | None = None
|
|
384
|
+
if "rows" not in payload:
|
|
385
|
+
if "fields" in payload:
|
|
386
|
+
shorthand = payload.pop("fields")
|
|
387
|
+
elif "field_ids" in payload:
|
|
388
|
+
shorthand = payload.pop("field_ids")
|
|
389
|
+
if shorthand is not None:
|
|
390
|
+
payload["rows"] = _normalize_layout_rows(
|
|
391
|
+
shorthand,
|
|
392
|
+
columns=_coerce_layout_columns(payload.pop("columns", None)),
|
|
393
|
+
)
|
|
394
|
+
return payload
|
|
395
|
+
|
|
396
|
+
@model_validator(mode="after")
|
|
397
|
+
def validate_rows(self) -> "LayoutSectionPatch":
|
|
398
|
+
if not self.rows:
|
|
399
|
+
raise ValueError("section rows must be a non-empty list")
|
|
400
|
+
for row in self.rows:
|
|
401
|
+
if not isinstance(row, list) or not row:
|
|
402
|
+
raise ValueError("section rows must be a non-empty list")
|
|
403
|
+
if not self.section_id:
|
|
404
|
+
self.section_id = _slugify_title(self.title)
|
|
405
|
+
return self
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class FlowNodePatch(StrictModel):
|
|
409
|
+
id: str
|
|
410
|
+
type: PublicFlowNodeType
|
|
411
|
+
name: str
|
|
412
|
+
assignees: FlowAssigneePatch = Field(default_factory=FlowAssigneePatch)
|
|
413
|
+
permissions: FlowNodePermissionsPatch = Field(default_factory=FlowNodePermissionsPatch)
|
|
414
|
+
conditions: list[FlowConditionRulePatch] = Field(default_factory=list)
|
|
415
|
+
condition_groups: list[list[FlowConditionRulePatch]] = Field(default_factory=list)
|
|
416
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
417
|
+
|
|
418
|
+
@model_validator(mode="before")
|
|
419
|
+
@classmethod
|
|
420
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
421
|
+
if not isinstance(value, dict):
|
|
422
|
+
return value
|
|
423
|
+
payload = dict(value)
|
|
424
|
+
assignees = dict(payload.get("assignees") or {})
|
|
425
|
+
permissions = dict(payload.get("permissions") or {})
|
|
426
|
+
|
|
427
|
+
for key in ("role_ids", "role_names", "member_uids", "member_emails", "member_names", "include_sub_departs"):
|
|
428
|
+
if key in payload and key not in assignees:
|
|
429
|
+
assignees[key] = payload.pop(key)
|
|
430
|
+
for key in ("editable_fields",):
|
|
431
|
+
if key in payload and key not in permissions:
|
|
432
|
+
permissions[key] = payload.pop(key)
|
|
433
|
+
if "filters" in payload and "conditions" not in payload:
|
|
434
|
+
payload["conditions"] = payload.pop("filters")
|
|
435
|
+
if "rules" in payload and "conditions" not in payload:
|
|
436
|
+
payload["conditions"] = payload.pop("rules")
|
|
437
|
+
if "conditionRules" in payload and "condition_groups" not in payload:
|
|
438
|
+
payload["condition_groups"] = payload.pop("conditionRules")
|
|
439
|
+
if "conditionGroups" in payload and "condition_groups" not in payload:
|
|
440
|
+
payload["condition_groups"] = payload.pop("conditionGroups")
|
|
441
|
+
if "owners" in payload and "member_names" not in assignees:
|
|
442
|
+
assignees["member_names"] = payload.pop("owners")
|
|
443
|
+
if "approvers" in payload and "role_names" not in assignees:
|
|
444
|
+
assignees["role_names"] = payload.pop("approvers")
|
|
445
|
+
if assignees:
|
|
446
|
+
payload["assignees"] = assignees
|
|
447
|
+
if permissions:
|
|
448
|
+
payload["permissions"] = permissions
|
|
449
|
+
return payload
|
|
450
|
+
|
|
451
|
+
@model_validator(mode="after")
|
|
452
|
+
def validate_branch_conditions(self) -> "FlowNodePatch":
|
|
453
|
+
if self.conditions:
|
|
454
|
+
self.condition_groups = [list(self.conditions), *self.condition_groups]
|
|
455
|
+
self.conditions = []
|
|
456
|
+
if self.type != PublicFlowNodeType.condition and self.condition_groups:
|
|
457
|
+
raise ValueError("condition_groups are only allowed on condition nodes")
|
|
458
|
+
return self
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class FlowTransitionPatch(StrictModel):
|
|
462
|
+
source: str = Field(alias="from")
|
|
463
|
+
target: str = Field(alias="to")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class ViewUpsertPatch(StrictModel):
|
|
467
|
+
name: str
|
|
468
|
+
view_key: str | None = Field(default=None, validation_alias=AliasChoices("view_key", "viewKey"))
|
|
469
|
+
type: PublicViewType
|
|
470
|
+
columns: list[str] = Field(default_factory=list)
|
|
471
|
+
group_by: str | None = None
|
|
472
|
+
filters: list[ViewFilterRulePatch] = Field(default_factory=list)
|
|
473
|
+
start_field: str | None = Field(default=None, validation_alias=AliasChoices("start_field", "startField"))
|
|
474
|
+
end_field: str | None = Field(default=None, validation_alias=AliasChoices("end_field", "endField"))
|
|
475
|
+
title_field: str | None = Field(default=None, validation_alias=AliasChoices("title_field", "titleField"))
|
|
476
|
+
|
|
477
|
+
@model_validator(mode="before")
|
|
478
|
+
@classmethod
|
|
479
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
480
|
+
if not isinstance(value, dict):
|
|
481
|
+
return value
|
|
482
|
+
payload = dict(value)
|
|
483
|
+
if "fields" in payload and "columns" not in payload:
|
|
484
|
+
payload["columns"] = payload.pop("fields")
|
|
485
|
+
if "column_names" in payload and "columns" not in payload:
|
|
486
|
+
payload["columns"] = payload.pop("column_names")
|
|
487
|
+
if "columnNames" in payload and "columns" not in payload:
|
|
488
|
+
payload["columns"] = payload.pop("columnNames")
|
|
489
|
+
if "filter_rules" in payload and "filters" not in payload:
|
|
490
|
+
payload["filters"] = payload.pop("filter_rules")
|
|
491
|
+
if "filterRules" in payload and "filters" not in payload:
|
|
492
|
+
payload["filters"] = payload.pop("filterRules")
|
|
493
|
+
raw_type = payload.get("type")
|
|
494
|
+
if isinstance(raw_type, str):
|
|
495
|
+
normalized = raw_type.strip().lower()
|
|
496
|
+
if normalized == "tableview":
|
|
497
|
+
payload["type"] = "table"
|
|
498
|
+
elif normalized == "cardview":
|
|
499
|
+
payload["type"] = "card"
|
|
500
|
+
elif normalized == "kanban":
|
|
501
|
+
payload["type"] = "board"
|
|
502
|
+
elif normalized == "ganttview":
|
|
503
|
+
payload["type"] = "gantt"
|
|
504
|
+
return payload
|
|
505
|
+
|
|
506
|
+
@model_validator(mode="after")
|
|
507
|
+
def validate_shape(self) -> "ViewUpsertPatch":
|
|
508
|
+
if self.type in {PublicViewType.table, PublicViewType.card} and not self.columns:
|
|
509
|
+
raise ValueError("table/card views require columns")
|
|
510
|
+
if self.type == PublicViewType.board and not self.group_by:
|
|
511
|
+
raise ValueError("board view requires group_by")
|
|
512
|
+
if self.type == PublicViewType.gantt and not (self.start_field and self.end_field):
|
|
513
|
+
raise ValueError("gantt view requires start_field and end_field")
|
|
514
|
+
return self
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class ChartFilterRulePatch(StrictModel):
|
|
518
|
+
field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
|
|
519
|
+
operator: ViewFilterOperator = Field(validation_alias=AliasChoices("operator", "op"))
|
|
520
|
+
values: list[Any] = Field(default_factory=list)
|
|
521
|
+
|
|
522
|
+
@model_validator(mode="before")
|
|
523
|
+
@classmethod
|
|
524
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
525
|
+
if not isinstance(value, dict):
|
|
526
|
+
return value
|
|
527
|
+
payload = dict(value)
|
|
528
|
+
if "value" in payload and "values" not in payload:
|
|
529
|
+
payload["values"] = [payload.pop("value")]
|
|
530
|
+
raw_operator = payload.get("operator", payload.get("op"))
|
|
531
|
+
if isinstance(raw_operator, str):
|
|
532
|
+
normalized = raw_operator.strip().lower()
|
|
533
|
+
operator_aliases = {
|
|
534
|
+
"equals": ViewFilterOperator.eq.value,
|
|
535
|
+
"equal": ViewFilterOperator.eq.value,
|
|
536
|
+
"=": ViewFilterOperator.eq.value,
|
|
537
|
+
"not_equals": ViewFilterOperator.neq.value,
|
|
538
|
+
"not_equal": ViewFilterOperator.neq.value,
|
|
539
|
+
"!=": ViewFilterOperator.neq.value,
|
|
540
|
+
">=": ViewFilterOperator.gte.value,
|
|
541
|
+
"<=": ViewFilterOperator.lte.value,
|
|
542
|
+
"any_of": ViewFilterOperator.in_.value,
|
|
543
|
+
"one_of": ViewFilterOperator.in_.value,
|
|
544
|
+
"between_any": ViewFilterOperator.in_.value,
|
|
545
|
+
"empty": ViewFilterOperator.is_empty.value,
|
|
546
|
+
"is blank": ViewFilterOperator.is_empty.value,
|
|
547
|
+
"blank": ViewFilterOperator.is_empty.value,
|
|
548
|
+
"not_empty": ViewFilterOperator.not_empty.value,
|
|
549
|
+
"not blank": ViewFilterOperator.not_empty.value,
|
|
550
|
+
}
|
|
551
|
+
if normalized in operator_aliases:
|
|
552
|
+
payload["operator"] = operator_aliases[normalized]
|
|
553
|
+
elif "operator" not in payload:
|
|
554
|
+
payload["operator"] = normalized
|
|
555
|
+
payload.pop("op", None)
|
|
556
|
+
return payload
|
|
557
|
+
|
|
558
|
+
@model_validator(mode="after")
|
|
559
|
+
def validate_shape(self) -> "ChartFilterRulePatch":
|
|
560
|
+
if self.operator in {ViewFilterOperator.is_empty, ViewFilterOperator.not_empty}:
|
|
561
|
+
self.values = []
|
|
562
|
+
return self
|
|
563
|
+
if not self.values:
|
|
564
|
+
raise ValueError("chart filter rule requires values")
|
|
565
|
+
return self
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class ChartUpsertPatch(StrictModel):
|
|
569
|
+
chart_id: str | None = None
|
|
570
|
+
name: str
|
|
571
|
+
chart_type: PublicChartType
|
|
572
|
+
dimension_field_ids: list[str] = Field(default_factory=list)
|
|
573
|
+
indicator_field_ids: list[str] = Field(default_factory=list)
|
|
574
|
+
filters: list[ChartFilterRulePatch] = Field(default_factory=list)
|
|
575
|
+
question_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
576
|
+
user_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
577
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
578
|
+
|
|
579
|
+
@model_validator(mode="before")
|
|
580
|
+
@classmethod
|
|
581
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
582
|
+
if not isinstance(value, dict):
|
|
583
|
+
return value
|
|
584
|
+
payload = dict(value)
|
|
585
|
+
if "id" in payload and "chart_id" not in payload:
|
|
586
|
+
payload["chart_id"] = payload.pop("id")
|
|
587
|
+
if "type" in payload and "chart_type" not in payload:
|
|
588
|
+
payload["chart_type"] = payload.pop("type")
|
|
589
|
+
if "dimension_fields" in payload and "dimension_field_ids" not in payload:
|
|
590
|
+
payload["dimension_field_ids"] = payload.pop("dimension_fields")
|
|
591
|
+
if "indicator_fields" in payload and "indicator_field_ids" not in payload:
|
|
592
|
+
payload["indicator_field_ids"] = payload.pop("indicator_fields")
|
|
593
|
+
if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
|
|
594
|
+
payload["indicator_field_ids"] = payload.pop("metric_field_ids")
|
|
595
|
+
raw_type = payload.get("chart_type")
|
|
596
|
+
if isinstance(raw_type, str):
|
|
597
|
+
normalized = raw_type.strip().lower()
|
|
598
|
+
aliases = {
|
|
599
|
+
"targetchart": PublicChartType.target.value,
|
|
600
|
+
"piechart": PublicChartType.pie.value,
|
|
601
|
+
"barchart": PublicChartType.bar.value,
|
|
602
|
+
"linechart": PublicChartType.line.value,
|
|
603
|
+
"tablechart": PublicChartType.table.value,
|
|
604
|
+
}
|
|
605
|
+
if normalized in aliases:
|
|
606
|
+
payload["chart_type"] = aliases[normalized]
|
|
607
|
+
if isinstance(payload.get("chart_id"), int):
|
|
608
|
+
payload["chart_id"] = str(payload["chart_id"])
|
|
609
|
+
if isinstance(payload.get("dimension_field_ids"), list):
|
|
610
|
+
payload["dimension_field_ids"] = [str(item) for item in payload["dimension_field_ids"] if item is not None and str(item).strip()]
|
|
611
|
+
if isinstance(payload.get("indicator_field_ids"), list):
|
|
612
|
+
payload["indicator_field_ids"] = [str(item) for item in payload["indicator_field_ids"] if item is not None and str(item).strip()]
|
|
613
|
+
return payload
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
class ChartApplyRequest(StrictModel):
|
|
617
|
+
app_key: str
|
|
618
|
+
upsert_charts: list[ChartUpsertPatch] = Field(default_factory=list)
|
|
619
|
+
remove_chart_ids: list[str] = Field(default_factory=list)
|
|
620
|
+
reorder_chart_ids: list[str] = Field(default_factory=list)
|
|
621
|
+
|
|
622
|
+
@model_validator(mode="before")
|
|
623
|
+
@classmethod
|
|
624
|
+
def normalize_ids(cls, value: Any) -> Any:
|
|
625
|
+
if not isinstance(value, dict):
|
|
626
|
+
return value
|
|
627
|
+
payload = dict(value)
|
|
628
|
+
for key in ("remove_chart_ids", "reorder_chart_ids"):
|
|
629
|
+
raw = payload.get(key)
|
|
630
|
+
if isinstance(raw, list):
|
|
631
|
+
payload[key] = [str(item) for item in raw if item is not None and str(item).strip()]
|
|
632
|
+
return payload
|
|
633
|
+
|
|
634
|
+
@model_validator(mode="after")
|
|
635
|
+
def validate_shape(self) -> "ChartApplyRequest":
|
|
636
|
+
if not self.upsert_charts and not self.remove_chart_ids and not self.reorder_chart_ids:
|
|
637
|
+
raise ValueError("chart apply requires at least one upsert, remove, or reorder operation")
|
|
638
|
+
return self
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class PortalComponentPositionPatch(StrictModel):
|
|
642
|
+
pc_x: int = Field(default=0, validation_alias=AliasChoices("pc_x", "pcX", "x"))
|
|
643
|
+
pc_y: int = Field(default=0, validation_alias=AliasChoices("pc_y", "pcY", "y"))
|
|
644
|
+
pc_w: int = Field(default=12, validation_alias=AliasChoices("pc_w", "pcW", "w"))
|
|
645
|
+
pc_h: int = Field(default=8, validation_alias=AliasChoices("pc_h", "pcH", "h"))
|
|
646
|
+
mobile_x: int = Field(default=0, validation_alias=AliasChoices("mobile_x", "mobileX"))
|
|
647
|
+
mobile_y: int = Field(default=0, validation_alias=AliasChoices("mobile_y", "mobileY"))
|
|
648
|
+
mobile_w: int = Field(default=12, validation_alias=AliasChoices("mobile_w", "mobileW"))
|
|
649
|
+
mobile_h: int = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
|
|
650
|
+
|
|
651
|
+
@model_validator(mode="before")
|
|
652
|
+
@classmethod
|
|
653
|
+
def normalize_nested_layout(cls, value: Any) -> Any:
|
|
654
|
+
if not isinstance(value, dict):
|
|
655
|
+
return value
|
|
656
|
+
payload = dict(value)
|
|
657
|
+
pc = payload.pop("pc", None)
|
|
658
|
+
mobile = payload.pop("mobile", None)
|
|
659
|
+
if isinstance(pc, dict):
|
|
660
|
+
if "pc_x" not in payload and "x" in pc:
|
|
661
|
+
payload["pc_x"] = pc.get("x")
|
|
662
|
+
if "pc_y" not in payload and "y" in pc:
|
|
663
|
+
payload["pc_y"] = pc.get("y")
|
|
664
|
+
if "pc_w" not in payload and "cols" in pc:
|
|
665
|
+
payload["pc_w"] = pc.get("cols")
|
|
666
|
+
if "pc_h" not in payload and "rows" in pc:
|
|
667
|
+
payload["pc_h"] = pc.get("rows")
|
|
668
|
+
if isinstance(mobile, dict):
|
|
669
|
+
if "mobile_x" not in payload and "x" in mobile:
|
|
670
|
+
payload["mobile_x"] = mobile.get("x")
|
|
671
|
+
if "mobile_y" not in payload and "y" in mobile:
|
|
672
|
+
payload["mobile_y"] = mobile.get("y")
|
|
673
|
+
if "mobile_w" not in payload and "cols" in mobile:
|
|
674
|
+
payload["mobile_w"] = mobile.get("cols")
|
|
675
|
+
if "mobile_h" not in payload and "rows" in mobile:
|
|
676
|
+
payload["mobile_h"] = mobile.get("rows")
|
|
677
|
+
return payload
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
class PortalChartRefPatch(StrictModel):
|
|
681
|
+
app_key: str
|
|
682
|
+
chart_id: str | None = None
|
|
683
|
+
chart_name: str | None = None
|
|
684
|
+
|
|
685
|
+
@model_validator(mode="after")
|
|
686
|
+
def validate_target(self) -> "PortalChartRefPatch":
|
|
687
|
+
if not (self.chart_id or self.chart_name):
|
|
688
|
+
raise ValueError("chart_ref requires chart_id or chart_name")
|
|
689
|
+
return self
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
class PortalViewRefPatch(StrictModel):
|
|
693
|
+
app_key: str
|
|
694
|
+
view_key: str | None = None
|
|
695
|
+
view_name: str | None = None
|
|
696
|
+
|
|
697
|
+
@model_validator(mode="after")
|
|
698
|
+
def validate_target(self) -> "PortalViewRefPatch":
|
|
699
|
+
if not (self.view_key or self.view_name):
|
|
700
|
+
raise ValueError("view_ref requires view_key or view_name")
|
|
701
|
+
return self
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
class PortalSectionPatch(StrictModel):
|
|
705
|
+
title: str
|
|
706
|
+
source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
|
|
707
|
+
position: PortalComponentPositionPatch | None = None
|
|
708
|
+
dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
|
|
709
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
710
|
+
chart_ref: PortalChartRefPatch | None = None
|
|
711
|
+
view_ref: PortalViewRefPatch | None = None
|
|
712
|
+
text: str | None = None
|
|
713
|
+
url: str | None = None
|
|
714
|
+
|
|
715
|
+
@model_validator(mode="before")
|
|
716
|
+
@classmethod
|
|
717
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
718
|
+
if not isinstance(value, dict):
|
|
719
|
+
return value
|
|
720
|
+
payload = dict(value)
|
|
721
|
+
raw_type = payload.get("source_type", payload.get("sourceType"))
|
|
722
|
+
if isinstance(raw_type, str):
|
|
723
|
+
payload["source_type"] = raw_type.strip().lower()
|
|
724
|
+
if "chartRef" in payload and "chart_ref" not in payload:
|
|
725
|
+
payload["chart_ref"] = payload.pop("chartRef")
|
|
726
|
+
if "viewRef" in payload and "view_ref" not in payload:
|
|
727
|
+
payload["view_ref"] = payload.pop("viewRef")
|
|
728
|
+
if "dashStyleConfigBO" in payload and "dash_style_config" not in payload:
|
|
729
|
+
payload["dash_style_config"] = payload.pop("dashStyleConfigBO")
|
|
730
|
+
return payload
|
|
731
|
+
|
|
732
|
+
@model_validator(mode="after")
|
|
733
|
+
def validate_shape(self) -> "PortalSectionPatch":
|
|
734
|
+
supported = {"chart", "view", "grid", "filter", "text", "link"}
|
|
735
|
+
if self.source_type not in supported:
|
|
736
|
+
raise ValueError(f"unsupported portal source_type '{self.source_type}'")
|
|
737
|
+
if self.source_type == "chart" and self.chart_ref is None:
|
|
738
|
+
raise ValueError("chart section requires chart_ref")
|
|
739
|
+
if self.source_type == "view" and self.view_ref is None:
|
|
740
|
+
raise ValueError("view section requires view_ref")
|
|
741
|
+
if self.source_type == "text" and self.text is None:
|
|
742
|
+
raise ValueError("text section requires text")
|
|
743
|
+
if self.source_type == "link" and self.url is None:
|
|
744
|
+
raise ValueError("link section requires url")
|
|
745
|
+
return self
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
class PortalApplyRequest(StrictModel):
|
|
749
|
+
dash_key: str | None = None
|
|
750
|
+
dash_name: str | None = None
|
|
751
|
+
package_tag_id: int | None = None
|
|
752
|
+
publish: bool = True
|
|
753
|
+
sections: list[PortalSectionPatch] = Field(default_factory=list)
|
|
754
|
+
auth: dict[str, Any] | None = None
|
|
755
|
+
icon: str | None = None
|
|
756
|
+
color: str | None = None
|
|
757
|
+
hide_copyright: bool | None = Field(default=None, validation_alias=AliasChoices("hide_copyright", "hideCopyright"))
|
|
758
|
+
dash_global_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_global_config", "dashGlobalConfig"))
|
|
759
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
760
|
+
|
|
761
|
+
@model_validator(mode="after")
|
|
762
|
+
def validate_shape(self) -> "PortalApplyRequest":
|
|
763
|
+
if not self.dash_key and not self.package_tag_id:
|
|
764
|
+
raise ValueError("package_tag_id is required when dash_key is empty")
|
|
765
|
+
if not self.dash_key and not self.dash_name:
|
|
766
|
+
raise ValueError("dash_name is required when creating a portal")
|
|
767
|
+
if not self.sections:
|
|
768
|
+
raise ValueError("portal apply requires a non-empty sections list")
|
|
769
|
+
return self
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
FieldPatch.model_rebuild()
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
class AppReadSummaryResponse(StrictModel):
|
|
776
|
+
app_key: str
|
|
777
|
+
title: str | None = None
|
|
778
|
+
tag_ids: list[int] = Field(default_factory=list)
|
|
779
|
+
publish_status: int | None = None
|
|
780
|
+
field_count: int = 0
|
|
781
|
+
layout_section_count: int = 0
|
|
782
|
+
view_count: int = 0
|
|
783
|
+
workflow_enabled: bool = False
|
|
784
|
+
verification_hints: list[str] = Field(default_factory=list)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
class AppFieldsReadResponse(StrictModel):
|
|
788
|
+
app_key: str
|
|
789
|
+
fields: list[dict[str, Any]] = Field(default_factory=list)
|
|
790
|
+
field_count: int = 0
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
class AppLayoutReadResponse(StrictModel):
|
|
794
|
+
app_key: str
|
|
795
|
+
sections: list[dict[str, Any]] = Field(default_factory=list)
|
|
796
|
+
unplaced_fields: list[str] = Field(default_factory=list)
|
|
797
|
+
layout_mode_detected: str = "empty"
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class AppViewsReadResponse(StrictModel):
|
|
801
|
+
app_key: str
|
|
802
|
+
views: list[dict[str, Any]] = Field(default_factory=list)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
class AppFlowReadResponse(StrictModel):
|
|
806
|
+
app_key: str
|
|
807
|
+
enabled: bool = False
|
|
808
|
+
nodes: list[dict[str, Any]] = Field(default_factory=list)
|
|
809
|
+
transitions: list[dict[str, Any]] = Field(default_factory=list)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
class AppChartsReadResponse(StrictModel):
|
|
813
|
+
app_key: str
|
|
814
|
+
charts: list[dict[str, Any]] = Field(default_factory=list)
|
|
815
|
+
chart_count: int = 0
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class PortalReadSummaryResponse(StrictModel):
|
|
819
|
+
dash_key: str
|
|
820
|
+
being_draft: bool = True
|
|
821
|
+
dash_name: str | None = None
|
|
822
|
+
package_tag_ids: list[int] = Field(default_factory=list)
|
|
823
|
+
dash_icon: str | None = None
|
|
824
|
+
hide_copyright: bool | None = None
|
|
825
|
+
config_keys: list[str] = Field(default_factory=list)
|
|
826
|
+
dash_global_config_keys: list[str] = Field(default_factory=list)
|
|
827
|
+
section_count: int = 0
|
|
828
|
+
sections: list[dict[str, Any]] = Field(default_factory=list)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class SchemaPlanRequest(StrictModel):
|
|
832
|
+
app_key: str = ""
|
|
833
|
+
package_tag_id: int | None = None
|
|
834
|
+
app_name: str = Field(default="", validation_alias=AliasChoices("app_name", "app_title", "title"))
|
|
835
|
+
create_if_missing: bool = False
|
|
836
|
+
add_fields: list[FieldPatch] = Field(default_factory=list)
|
|
837
|
+
update_fields: list[FieldUpdatePatch] = Field(default_factory=list)
|
|
838
|
+
remove_fields: list[FieldRemovePatch] = Field(default_factory=list)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
class LayoutPlanRequest(StrictModel):
|
|
842
|
+
app_key: str
|
|
843
|
+
mode: LayoutApplyMode = LayoutApplyMode.merge
|
|
844
|
+
sections: list[LayoutSectionPatch] = Field(default_factory=list)
|
|
845
|
+
preset: LayoutPreset | None = None
|
|
846
|
+
|
|
847
|
+
@model_validator(mode="before")
|
|
848
|
+
@classmethod
|
|
849
|
+
def normalize_mode_alias(cls, value: Any) -> Any:
|
|
850
|
+
if not isinstance(value, dict):
|
|
851
|
+
return value
|
|
852
|
+
payload = dict(value)
|
|
853
|
+
if str(payload.get("mode") or "").strip().lower() == "overwrite":
|
|
854
|
+
payload["mode"] = "replace"
|
|
855
|
+
return payload
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
class FlowPlanRequest(StrictModel):
|
|
859
|
+
app_key: str
|
|
860
|
+
mode: str = "replace"
|
|
861
|
+
nodes: list[FlowNodePatch] = Field(default_factory=list)
|
|
862
|
+
transitions: list[FlowTransitionPatch] = Field(default_factory=list)
|
|
863
|
+
preset: FlowPreset | None = None
|
|
864
|
+
|
|
865
|
+
@model_validator(mode="before")
|
|
866
|
+
@classmethod
|
|
867
|
+
def normalize_mode_alias(cls, value: Any) -> Any:
|
|
868
|
+
if not isinstance(value, dict):
|
|
869
|
+
return value
|
|
870
|
+
payload = dict(value)
|
|
871
|
+
if str(payload.get("mode") or "").strip().lower() == "overwrite":
|
|
872
|
+
payload["mode"] = "replace"
|
|
873
|
+
raw_preset = payload.get("preset")
|
|
874
|
+
if raw_preset is None and isinstance(payload.get("base_preset"), str):
|
|
875
|
+
raw_preset = payload["base_preset"]
|
|
876
|
+
payload["preset"] = raw_preset
|
|
877
|
+
if isinstance(raw_preset, str):
|
|
878
|
+
normalized_preset = raw_preset.strip().lower()
|
|
879
|
+
preset_aliases = {
|
|
880
|
+
"default_approval": FlowPreset.basic_approval.value,
|
|
881
|
+
"approval": FlowPreset.basic_approval.value,
|
|
882
|
+
"basic approval": FlowPreset.basic_approval.value,
|
|
883
|
+
"default_fill_then_approve": FlowPreset.basic_fill_then_approve.value,
|
|
884
|
+
"default-fill-then-approve": FlowPreset.basic_fill_then_approve.value,
|
|
885
|
+
"fill_then_approve": FlowPreset.basic_fill_then_approve.value,
|
|
886
|
+
}
|
|
887
|
+
if normalized_preset in preset_aliases:
|
|
888
|
+
payload["preset"] = preset_aliases[normalized_preset]
|
|
889
|
+
return payload
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
class ViewsPlanRequest(StrictModel):
|
|
893
|
+
app_key: str
|
|
894
|
+
upsert_views: list[ViewUpsertPatch] = Field(default_factory=list)
|
|
895
|
+
remove_views: list[str] = Field(default_factory=list)
|
|
896
|
+
preset: ViewsPreset | None = None
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
class OperationResultEnvelope(StrictModel):
|
|
900
|
+
status: str
|
|
901
|
+
error_code: str | None = None
|
|
902
|
+
recoverable: bool = False
|
|
903
|
+
message: str
|
|
904
|
+
normalized_args: dict[str, Any] = Field(default_factory=dict)
|
|
905
|
+
missing_fields: list[str] = Field(default_factory=list)
|
|
906
|
+
allowed_values: dict[str, Any] = Field(default_factory=dict)
|
|
907
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
908
|
+
request_id: str | None = None
|
|
909
|
+
suggested_next_call: dict[str, Any] | None = None
|
|
910
|
+
noop: bool = False
|
|
911
|
+
verification: dict[str, Any] = Field(default_factory=dict)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _normalize_field_payload(value: Any) -> Any:
|
|
915
|
+
if not isinstance(value, dict):
|
|
916
|
+
return value
|
|
917
|
+
payload = dict(value)
|
|
918
|
+
if "fields" in payload and "subfields" not in payload:
|
|
919
|
+
payload["subfields"] = payload.pop("fields")
|
|
920
|
+
raw_type = payload.get("type")
|
|
921
|
+
if isinstance(raw_type, int):
|
|
922
|
+
normalized_from_id = FIELD_TYPE_ID_ALIASES.get(raw_type)
|
|
923
|
+
if normalized_from_id is not None:
|
|
924
|
+
payload["type"] = normalized_from_id.value
|
|
925
|
+
if isinstance(raw_type, str):
|
|
926
|
+
normalized = FIELD_TYPE_ALIASES.get(raw_type.strip().lower())
|
|
927
|
+
if normalized is not None:
|
|
928
|
+
payload["type"] = normalized.value
|
|
929
|
+
normalized_relation_mode = _normalize_public_relation_mode(
|
|
930
|
+
payload.get("relation_mode", payload.get("relationMode", payload.get("selection_mode", payload.get("selectionMode"))))
|
|
931
|
+
)
|
|
932
|
+
if normalized_relation_mode is None:
|
|
933
|
+
for alias_key in ("optional_data_num", "optionalDataNum", "multiple", "allow_multiple"):
|
|
934
|
+
if alias_key in payload:
|
|
935
|
+
normalized_relation_mode = _normalize_public_relation_mode(payload.get(alias_key))
|
|
936
|
+
break
|
|
937
|
+
if normalized_relation_mode is not None:
|
|
938
|
+
payload["relation_mode"] = normalized_relation_mode
|
|
939
|
+
for alias_key in (
|
|
940
|
+
"relationMode",
|
|
941
|
+
"selection_mode",
|
|
942
|
+
"selectionMode",
|
|
943
|
+
"optional_data_num",
|
|
944
|
+
"optionalDataNum",
|
|
945
|
+
"multiple",
|
|
946
|
+
"allow_multiple",
|
|
947
|
+
):
|
|
948
|
+
payload.pop(alias_key, None)
|
|
949
|
+
return payload
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _slugify_title(title: str) -> str:
|
|
953
|
+
normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(title or ""))
|
|
954
|
+
collapsed = "_".join(part for part in normalized.split("_") if part)
|
|
955
|
+
return collapsed or "section"
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _normalize_public_relation_mode(value: Any) -> str | None:
|
|
959
|
+
if value is None:
|
|
960
|
+
return None
|
|
961
|
+
if isinstance(value, bool):
|
|
962
|
+
return PublicRelationMode.multiple.value if value else PublicRelationMode.single.value
|
|
963
|
+
if isinstance(value, int):
|
|
964
|
+
if value == 0:
|
|
965
|
+
return PublicRelationMode.multiple.value
|
|
966
|
+
if value == 1:
|
|
967
|
+
return PublicRelationMode.single.value
|
|
968
|
+
return None
|
|
969
|
+
if isinstance(value, str):
|
|
970
|
+
normalized = value.strip().lower()
|
|
971
|
+
aliases = {
|
|
972
|
+
"single": PublicRelationMode.single.value,
|
|
973
|
+
"single_select": PublicRelationMode.single.value,
|
|
974
|
+
"single-select": PublicRelationMode.single.value,
|
|
975
|
+
"one": PublicRelationMode.single.value,
|
|
976
|
+
"1": PublicRelationMode.single.value,
|
|
977
|
+
"multiple": PublicRelationMode.multiple.value,
|
|
978
|
+
"multi": PublicRelationMode.multiple.value,
|
|
979
|
+
"multi_select": PublicRelationMode.multiple.value,
|
|
980
|
+
"multi-select": PublicRelationMode.multiple.value,
|
|
981
|
+
"many": PublicRelationMode.multiple.value,
|
|
982
|
+
"0": PublicRelationMode.multiple.value,
|
|
983
|
+
}
|
|
984
|
+
return aliases.get(normalized, normalized or None)
|
|
985
|
+
return None
|