@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.
Files changed (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. 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