@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,853 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StrictModel(BaseModel):
|
|
10
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EntityKind(str, Enum):
|
|
14
|
+
master = "master"
|
|
15
|
+
transaction = "transaction"
|
|
16
|
+
activity = "activity"
|
|
17
|
+
bridge = "bridge"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FieldType(str, Enum):
|
|
21
|
+
text = "text"
|
|
22
|
+
long_text = "long_text"
|
|
23
|
+
number = "number"
|
|
24
|
+
amount = "amount"
|
|
25
|
+
date = "date"
|
|
26
|
+
datetime = "datetime"
|
|
27
|
+
member = "member"
|
|
28
|
+
department = "department"
|
|
29
|
+
single_select = "single_select"
|
|
30
|
+
multi_select = "multi_select"
|
|
31
|
+
phone = "phone"
|
|
32
|
+
email = "email"
|
|
33
|
+
address = "address"
|
|
34
|
+
attachment = "attachment"
|
|
35
|
+
boolean = "boolean"
|
|
36
|
+
relation = "relation"
|
|
37
|
+
subtable = "subtable"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WorkflowNodeType(str, Enum):
|
|
41
|
+
start = "start"
|
|
42
|
+
branch = "branch"
|
|
43
|
+
audit = "audit"
|
|
44
|
+
fill = "fill"
|
|
45
|
+
copy = "copy"
|
|
46
|
+
webhook = "webhook"
|
|
47
|
+
qsource = "qsource"
|
|
48
|
+
condition = "condition"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ViewType(str, Enum):
|
|
52
|
+
table = "table"
|
|
53
|
+
card = "card"
|
|
54
|
+
board = "board"
|
|
55
|
+
gantt = "gantt"
|
|
56
|
+
hierarchy = "hierarchy"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ChartType(str, Enum):
|
|
60
|
+
bar = "bar"
|
|
61
|
+
line = "line"
|
|
62
|
+
pie = "pie"
|
|
63
|
+
summary = "summary"
|
|
64
|
+
data = "data"
|
|
65
|
+
target = "target"
|
|
66
|
+
funnel = "funnel"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RequirementPriority(str, Enum):
|
|
70
|
+
must = "must"
|
|
71
|
+
should = "should"
|
|
72
|
+
could = "could"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MetricAggregate(str, Enum):
|
|
76
|
+
count = "count"
|
|
77
|
+
sum = "sum"
|
|
78
|
+
avg = "avg"
|
|
79
|
+
distinct_count = "distinct_count"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PortalSourceType(str, Enum):
|
|
83
|
+
chart = "chart"
|
|
84
|
+
grid = "grid"
|
|
85
|
+
view = "view"
|
|
86
|
+
filter = "filter"
|
|
87
|
+
text = "text"
|
|
88
|
+
link = "link"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class NavigationTargetType(str, Enum):
|
|
92
|
+
package = "package"
|
|
93
|
+
app = "app"
|
|
94
|
+
chart = "chart"
|
|
95
|
+
portal = "portal"
|
|
96
|
+
view = "view"
|
|
97
|
+
custom_url = "custom_url"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PackageSpec(StrictModel):
|
|
101
|
+
enabled: bool = True
|
|
102
|
+
name: str | None = None
|
|
103
|
+
icon: str | None = None
|
|
104
|
+
color: str | None = None
|
|
105
|
+
ordinal: int = 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class BusinessContextSpec(StrictModel):
|
|
109
|
+
industry: str | None = None
|
|
110
|
+
subdomain: str | None = None
|
|
111
|
+
scenario: str | None = None
|
|
112
|
+
target_users: list[str] = Field(default_factory=list)
|
|
113
|
+
core_goals: list[str] = Field(default_factory=list)
|
|
114
|
+
terminology: dict[str, str] = Field(default_factory=dict)
|
|
115
|
+
non_goals: list[str] = Field(default_factory=list)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class FieldSpec(StrictModel):
|
|
119
|
+
field_id: str
|
|
120
|
+
label: str
|
|
121
|
+
type: FieldType
|
|
122
|
+
required: bool = False
|
|
123
|
+
unique: bool = False
|
|
124
|
+
description: str | None = None
|
|
125
|
+
options: list[str] = Field(default_factory=list)
|
|
126
|
+
target_entity_id: str | None = None
|
|
127
|
+
target_field_id: str | None = None
|
|
128
|
+
subfields: list["FieldSpec"] = Field(default_factory=list)
|
|
129
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
130
|
+
|
|
131
|
+
@model_validator(mode="after")
|
|
132
|
+
def validate_field_shape(self) -> "FieldSpec":
|
|
133
|
+
if self.type == FieldType.relation and not self.target_entity_id:
|
|
134
|
+
raise ValueError("relation field must declare target_entity_id")
|
|
135
|
+
if self.type != FieldType.relation and self.target_entity_id:
|
|
136
|
+
raise ValueError("target_entity_id is only allowed for relation fields")
|
|
137
|
+
if self.type == FieldType.subtable and not self.subfields:
|
|
138
|
+
raise ValueError("subtable field must declare subfields")
|
|
139
|
+
if self.type != FieldType.subtable and self.subfields:
|
|
140
|
+
raise ValueError("subfields are only allowed for subtable fields")
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class FormLayoutRowSpec(StrictModel):
|
|
145
|
+
field_ids: list[str] = Field(default_factory=list)
|
|
146
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
147
|
+
|
|
148
|
+
@model_validator(mode="after")
|
|
149
|
+
def validate_row(self) -> "FormLayoutRowSpec":
|
|
150
|
+
if not self.field_ids:
|
|
151
|
+
raise ValueError("form layout row must include at least one field_id")
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class FormLayoutSectionSpec(StrictModel):
|
|
156
|
+
section_id: str
|
|
157
|
+
title: str
|
|
158
|
+
rows: list[FormLayoutRowSpec] = Field(default_factory=list)
|
|
159
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
160
|
+
|
|
161
|
+
@model_validator(mode="after")
|
|
162
|
+
def validate_section(self) -> "FormLayoutSectionSpec":
|
|
163
|
+
if not self.rows:
|
|
164
|
+
raise ValueError("form layout section must include at least one row")
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class FormLayoutSpec(StrictModel):
|
|
169
|
+
rows: list[FormLayoutRowSpec] = Field(default_factory=list)
|
|
170
|
+
sections: list[FormLayoutSectionSpec] = Field(default_factory=list)
|
|
171
|
+
|
|
172
|
+
@model_validator(mode="after")
|
|
173
|
+
def validate_layout(self) -> "FormLayoutSpec":
|
|
174
|
+
if not self.rows and not self.sections:
|
|
175
|
+
raise ValueError("form_layout must include rows or sections")
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class RelationSpec(StrictModel):
|
|
180
|
+
relation_id: str
|
|
181
|
+
field_id: str
|
|
182
|
+
target_entity_id: str
|
|
183
|
+
target_field_id: str | None = None
|
|
184
|
+
relation_type: str = "many_to_one"
|
|
185
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class LifecycleStageSpec(StrictModel):
|
|
189
|
+
stage_id: str
|
|
190
|
+
name: str
|
|
191
|
+
terminal: bool = False
|
|
192
|
+
color: str | None = None
|
|
193
|
+
probability: float | None = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class WorkflowNodeSpec(StrictModel):
|
|
197
|
+
node_id: str
|
|
198
|
+
name: str
|
|
199
|
+
node_type: WorkflowNodeType
|
|
200
|
+
parent_node_id: str | None = None
|
|
201
|
+
branch_parent_id: str | None = None
|
|
202
|
+
branch_index: int | None = None
|
|
203
|
+
assignees: dict[str, Any] = Field(default_factory=dict)
|
|
204
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
205
|
+
|
|
206
|
+
@model_validator(mode="after")
|
|
207
|
+
def validate_branch_shape(self) -> "WorkflowNodeSpec":
|
|
208
|
+
if self.branch_index is not None and self.branch_index <= 0:
|
|
209
|
+
raise ValueError("branch_index must be positive")
|
|
210
|
+
if self.branch_parent_id and self.branch_index is None:
|
|
211
|
+
raise ValueError("branch_index is required when branch_parent_id is set")
|
|
212
|
+
if self.branch_index is not None and not self.branch_parent_id:
|
|
213
|
+
raise ValueError("branch_parent_id is required when branch_index is set")
|
|
214
|
+
if self.node_type == WorkflowNodeType.branch and self.branch_parent_id:
|
|
215
|
+
raise ValueError("branch node cannot declare branch_parent_id")
|
|
216
|
+
if self.node_type == WorkflowNodeType.branch and self.branch_index is not None:
|
|
217
|
+
raise ValueError("branch node cannot declare branch_index")
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class WorkflowSpec(StrictModel):
|
|
222
|
+
enabled: bool = True
|
|
223
|
+
global_settings: dict[str, Any] = Field(default_factory=dict)
|
|
224
|
+
nodes: list[WorkflowNodeSpec] = Field(default_factory=list)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class RoleEntityScopeSpec(StrictModel):
|
|
228
|
+
entity_id: str
|
|
229
|
+
can_create: bool = True
|
|
230
|
+
can_edit: bool = True
|
|
231
|
+
can_delete: bool = False
|
|
232
|
+
can_approve: bool = False
|
|
233
|
+
visible_field_ids: list[str] = Field(default_factory=list)
|
|
234
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class RoleSpec(StrictModel):
|
|
238
|
+
role_id: str
|
|
239
|
+
name: str
|
|
240
|
+
description: str | None = None
|
|
241
|
+
icon: str | None = None
|
|
242
|
+
users: list[int] = Field(default_factory=list)
|
|
243
|
+
entity_scopes: list[RoleEntityScopeSpec] = Field(default_factory=list)
|
|
244
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class RequirementSpec(StrictModel):
|
|
248
|
+
requirement_id: str
|
|
249
|
+
title: str
|
|
250
|
+
summary: str | None = None
|
|
251
|
+
priority: RequirementPriority = RequirementPriority.must
|
|
252
|
+
entity_ids: list[str] = Field(default_factory=list)
|
|
253
|
+
acceptance_criteria: list[str] = Field(default_factory=list)
|
|
254
|
+
tags: list[str] = Field(default_factory=list)
|
|
255
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class ViewSpec(StrictModel):
|
|
259
|
+
view_id: str
|
|
260
|
+
name: str
|
|
261
|
+
type: ViewType
|
|
262
|
+
field_ids: list[str] = Field(default_factory=list)
|
|
263
|
+
group_by_field_id: str | None = None
|
|
264
|
+
sort: list[dict[str, Any]] = Field(default_factory=list)
|
|
265
|
+
filters: list[dict[str, Any]] = Field(default_factory=list)
|
|
266
|
+
column_widths: dict[str, int] = Field(default_factory=dict)
|
|
267
|
+
member_config: dict[str, Any] = Field(default_factory=dict)
|
|
268
|
+
apply_config: dict[str, Any] = Field(default_factory=dict)
|
|
269
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
270
|
+
being_default: bool = False
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class ChartSpec(StrictModel):
|
|
274
|
+
chart_id: str
|
|
275
|
+
name: str
|
|
276
|
+
chart_type: ChartType
|
|
277
|
+
dimension_field_ids: list[str] = Field(default_factory=list)
|
|
278
|
+
indicator_field_ids: list[str] = Field(default_factory=list)
|
|
279
|
+
filters: list[dict[str, Any]] = Field(default_factory=list)
|
|
280
|
+
question_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
281
|
+
user_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
282
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class SampleRecordSpec(StrictModel):
|
|
286
|
+
record_id: str | None = None
|
|
287
|
+
values: dict[str, Any] = Field(default_factory=dict)
|
|
288
|
+
submit_type: int = 1
|
|
289
|
+
|
|
290
|
+
@model_validator(mode="after")
|
|
291
|
+
def validate_values(self) -> "SampleRecordSpec":
|
|
292
|
+
if not self.values:
|
|
293
|
+
raise ValueError("sample record must include values")
|
|
294
|
+
if self.submit_type not in (0, 1):
|
|
295
|
+
raise ValueError("sample record submit_type must be 0 or 1")
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class MetricSpec(StrictModel):
|
|
300
|
+
metric_id: str
|
|
301
|
+
name: str
|
|
302
|
+
entity_id: str
|
|
303
|
+
aggregate: MetricAggregate = MetricAggregate.count
|
|
304
|
+
field_id: str | None = None
|
|
305
|
+
group_by_field_id: str | None = None
|
|
306
|
+
time_field_id: str | None = None
|
|
307
|
+
visualization_preference: ChartType | None = None
|
|
308
|
+
target_value: float | None = None
|
|
309
|
+
description: str | None = None
|
|
310
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class PortalSectionSpec(StrictModel):
|
|
314
|
+
section_id: str
|
|
315
|
+
title: str
|
|
316
|
+
source_type: PortalSourceType
|
|
317
|
+
entity_id: str | None = None
|
|
318
|
+
chart_id: str | None = None
|
|
319
|
+
view_id: str | None = None
|
|
320
|
+
text: str | None = None
|
|
321
|
+
url: str | None = None
|
|
322
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class PortalSpec(StrictModel):
|
|
326
|
+
enabled: bool = True
|
|
327
|
+
name: str | None = None
|
|
328
|
+
icon: str | None = None
|
|
329
|
+
color: str | None = None
|
|
330
|
+
sections: list[PortalSectionSpec] = Field(default_factory=list)
|
|
331
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class NavigationItemSpec(StrictModel):
|
|
335
|
+
item_id: str
|
|
336
|
+
title: str
|
|
337
|
+
target_type: NavigationTargetType
|
|
338
|
+
entity_id: str | None = None
|
|
339
|
+
chart_id: str | None = None
|
|
340
|
+
view_id: str | None = None
|
|
341
|
+
url: str | None = None
|
|
342
|
+
icon: str | None = None
|
|
343
|
+
visible: bool = True
|
|
344
|
+
children: list["NavigationItemSpec"] = Field(default_factory=list)
|
|
345
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class NavigationSpec(StrictModel):
|
|
349
|
+
enabled: bool = True
|
|
350
|
+
items: list[NavigationItemSpec] = Field(default_factory=list)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class EntitySpec(StrictModel):
|
|
354
|
+
entity_id: str
|
|
355
|
+
display_name: str
|
|
356
|
+
plural_name: str | None = None
|
|
357
|
+
kind: EntityKind
|
|
358
|
+
aliases: list[str] = Field(default_factory=list)
|
|
359
|
+
business_purpose: str | None = None
|
|
360
|
+
description: str | None = None
|
|
361
|
+
fields: list[FieldSpec] = Field(default_factory=list)
|
|
362
|
+
form_layout: FormLayoutSpec | None = None
|
|
363
|
+
relations: list[RelationSpec] = Field(default_factory=list)
|
|
364
|
+
workflow: WorkflowSpec | None = None
|
|
365
|
+
views: list[ViewSpec] = Field(default_factory=list)
|
|
366
|
+
charts: list[ChartSpec] = Field(default_factory=list)
|
|
367
|
+
sample_records: list[SampleRecordSpec] = Field(default_factory=list)
|
|
368
|
+
title_field_id: str | None = None
|
|
369
|
+
status_field_id: str | None = None
|
|
370
|
+
owner_field_id: str | None = None
|
|
371
|
+
start_field_id: str | None = None
|
|
372
|
+
end_field_id: str | None = None
|
|
373
|
+
lifecycle_stages: list[LifecycleStageSpec] = Field(default_factory=list)
|
|
374
|
+
acceptance_criteria: list[str] = Field(default_factory=list)
|
|
375
|
+
portal_exposure: bool = True
|
|
376
|
+
navigation_exposure: bool = True
|
|
377
|
+
icon: str | None = None
|
|
378
|
+
color: str | None = None
|
|
379
|
+
ordinal: int | None = None
|
|
380
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
381
|
+
|
|
382
|
+
@model_validator(mode="after")
|
|
383
|
+
def validate_entity_uniqueness(self) -> "EntitySpec":
|
|
384
|
+
field_ids = [field.field_id for field in self.fields]
|
|
385
|
+
if len(field_ids) != len(set(field_ids)):
|
|
386
|
+
raise ValueError(f"entity '{self.entity_id}' has duplicate field_id values")
|
|
387
|
+
field_labels = [field.label for field in self.fields]
|
|
388
|
+
if len(field_labels) != len(set(field_labels)):
|
|
389
|
+
raise ValueError(f"entity '{self.entity_id}' has duplicate field labels")
|
|
390
|
+
relation_ids = [relation.relation_id for relation in self.relations]
|
|
391
|
+
if len(relation_ids) != len(set(relation_ids)):
|
|
392
|
+
raise ValueError(f"entity '{self.entity_id}' has duplicate relation_id values")
|
|
393
|
+
view_ids = [view.view_id for view in self.views]
|
|
394
|
+
if len(view_ids) != len(set(view_ids)):
|
|
395
|
+
raise ValueError(f"entity '{self.entity_id}' has duplicate view_id values")
|
|
396
|
+
chart_ids = [chart.chart_id for chart in self.charts]
|
|
397
|
+
if len(chart_ids) != len(set(chart_ids)):
|
|
398
|
+
raise ValueError(f"entity '{self.entity_id}' has duplicate chart_id values")
|
|
399
|
+
node_ids = [node.node_id for node in self.workflow.nodes] if self.workflow else []
|
|
400
|
+
if len(node_ids) != len(set(node_ids)):
|
|
401
|
+
raise ValueError(f"entity '{self.entity_id}' has duplicate workflow node_id values")
|
|
402
|
+
stage_ids = [stage.stage_id for stage in self.lifecycle_stages]
|
|
403
|
+
if len(stage_ids) != len(set(stage_ids)):
|
|
404
|
+
raise ValueError(f"entity '{self.entity_id}' has duplicate lifecycle stage_id values")
|
|
405
|
+
field_map = {field.field_id: field for field in self.fields}
|
|
406
|
+
for index, sample_record in enumerate(self.sample_records, start=1):
|
|
407
|
+
record_name = sample_record.record_id or f"sample#{index}"
|
|
408
|
+
for field_id, raw_value in sample_record.values.items():
|
|
409
|
+
field = field_map.get(field_id)
|
|
410
|
+
if field is None:
|
|
411
|
+
raise ValueError(f"entity '{self.entity_id}' sample record '{record_name}' references unknown field '{field_id}'")
|
|
412
|
+
_validate_sample_value(self.entity_id, record_name, field, raw_value)
|
|
413
|
+
return self
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class PublishPolicy(StrictModel):
|
|
417
|
+
apps: bool = True
|
|
418
|
+
portal: bool = True
|
|
419
|
+
navigation: bool = True
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class Preferences(StrictModel):
|
|
423
|
+
multi_app: bool | None = None
|
|
424
|
+
create_package: bool = True
|
|
425
|
+
create_portal: bool = True
|
|
426
|
+
create_navigation: bool = True
|
|
427
|
+
naming_style: str = "title"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class SolutionSpec(StrictModel):
|
|
431
|
+
solution_name: str
|
|
432
|
+
summary: str | None = None
|
|
433
|
+
business_context: BusinessContextSpec = Field(default_factory=BusinessContextSpec)
|
|
434
|
+
package: PackageSpec = Field(default_factory=PackageSpec)
|
|
435
|
+
entities: list[EntitySpec]
|
|
436
|
+
roles: list[RoleSpec] = Field(default_factory=list)
|
|
437
|
+
requirements: list[RequirementSpec] = Field(default_factory=list)
|
|
438
|
+
success_metrics: list[MetricSpec] = Field(default_factory=list)
|
|
439
|
+
portal: PortalSpec = Field(default_factory=PortalSpec)
|
|
440
|
+
navigation: NavigationSpec = Field(default_factory=NavigationSpec)
|
|
441
|
+
publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
|
|
442
|
+
preferences: Preferences = Field(default_factory=Preferences)
|
|
443
|
+
assumptions: list[str] = Field(default_factory=list)
|
|
444
|
+
constraints: list[str] = Field(default_factory=list)
|
|
445
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
446
|
+
|
|
447
|
+
@model_validator(mode="after")
|
|
448
|
+
def validate_cross_references(self) -> "SolutionSpec":
|
|
449
|
+
entity_map = {entity.entity_id: entity for entity in self.entities}
|
|
450
|
+
role_ids = {role.role_id for role in self.roles}
|
|
451
|
+
if len(entity_map) != len(self.entities):
|
|
452
|
+
raise ValueError("duplicate entity_id values are not allowed")
|
|
453
|
+
for entity in self.entities:
|
|
454
|
+
field_ids = self._effective_field_ids(entity)
|
|
455
|
+
title_field_id = entity.title_field_id or "title"
|
|
456
|
+
status_field_id = entity.status_field_id or "status"
|
|
457
|
+
if entity.relations:
|
|
458
|
+
raise ValueError(
|
|
459
|
+
f"entity '{entity.entity_id}' uses deprecated data relations; use relation fields (reference fields) instead"
|
|
460
|
+
)
|
|
461
|
+
if entity.kind in (EntityKind.master, EntityKind.transaction) and title_field_id not in field_ids:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"entity '{entity.entity_id}' must declare title field '{title_field_id}' explicitly for AI-authored DSL"
|
|
464
|
+
)
|
|
465
|
+
if ((entity.workflow and entity.workflow.enabled) or entity.lifecycle_stages) and status_field_id not in field_ids:
|
|
466
|
+
raise ValueError(
|
|
467
|
+
f"entity '{entity.entity_id}' must declare status field '{status_field_id}' explicitly for AI-authored DSL"
|
|
468
|
+
)
|
|
469
|
+
for field in entity.fields:
|
|
470
|
+
if field.type == FieldType.relation and field.target_entity_id not in entity_map:
|
|
471
|
+
raise ValueError(f"entity '{entity.entity_id}' relation field '{field.field_id}' targets unknown entity '{field.target_entity_id}'")
|
|
472
|
+
if field.type == FieldType.relation and field.target_entity_id in entity_map:
|
|
473
|
+
target_entity = entity_map[field.target_entity_id]
|
|
474
|
+
target_field_ids = self._effective_field_ids(target_entity)
|
|
475
|
+
target_field_id = field.target_field_id or target_entity.title_field_id or "title"
|
|
476
|
+
if target_field_id not in target_field_ids:
|
|
477
|
+
raise ValueError(
|
|
478
|
+
f"entity '{entity.entity_id}' relation field '{field.field_id}' targets unknown field '{target_field_id}' on entity '{field.target_entity_id}'"
|
|
479
|
+
)
|
|
480
|
+
if field.type == FieldType.subtable:
|
|
481
|
+
sub_ids = [subfield.field_id for subfield in field.subfields]
|
|
482
|
+
if len(sub_ids) != len(set(sub_ids)):
|
|
483
|
+
raise ValueError(f"entity '{entity.entity_id}' subtable field '{field.field_id}' has duplicate subfield ids")
|
|
484
|
+
self._validate_special_field_ref(entity.entity_id, "title_field_id", entity.title_field_id, field_ids)
|
|
485
|
+
self._validate_special_field_ref(entity.entity_id, "status_field_id", entity.status_field_id, field_ids)
|
|
486
|
+
self._validate_special_field_ref(entity.entity_id, "owner_field_id", entity.owner_field_id, field_ids)
|
|
487
|
+
self._validate_special_field_ref(entity.entity_id, "start_field_id", entity.start_field_id, field_ids)
|
|
488
|
+
self._validate_special_field_ref(entity.entity_id, "end_field_id", entity.end_field_id, field_ids)
|
|
489
|
+
for relation in entity.relations:
|
|
490
|
+
if relation.field_id not in field_ids:
|
|
491
|
+
raise ValueError(f"entity '{entity.entity_id}' relation '{relation.relation_id}' references unknown field '{relation.field_id}'")
|
|
492
|
+
if relation.target_entity_id not in entity_map:
|
|
493
|
+
raise ValueError(f"entity '{entity.entity_id}' relation '{relation.relation_id}' targets unknown entity '{relation.target_entity_id}'")
|
|
494
|
+
for view in entity.views:
|
|
495
|
+
self._validate_field_refs(entity.entity_id, view.view_id, view.field_ids, field_ids, "view")
|
|
496
|
+
if view.group_by_field_id and view.group_by_field_id not in field_ids:
|
|
497
|
+
raise ValueError(f"entity '{entity.entity_id}' view '{view.view_id}' references unknown field '{view.group_by_field_id}'")
|
|
498
|
+
for field_id in view.column_widths:
|
|
499
|
+
if field_id not in field_ids:
|
|
500
|
+
raise ValueError(f"entity '{entity.entity_id}' view '{view.view_id}' references unknown field '{field_id}'")
|
|
501
|
+
if entity.form_layout:
|
|
502
|
+
layout_field_ids: list[str] = []
|
|
503
|
+
for row in entity.form_layout.rows:
|
|
504
|
+
self._validate_field_refs(entity.entity_id, "form_layout", row.field_ids, field_ids, "form_layout")
|
|
505
|
+
layout_field_ids.extend(row.field_ids)
|
|
506
|
+
for section in entity.form_layout.sections:
|
|
507
|
+
for row in section.rows:
|
|
508
|
+
self._validate_field_refs(entity.entity_id, f"form_layout.{section.section_id}", row.field_ids, field_ids, "form_layout")
|
|
509
|
+
layout_field_ids.extend(row.field_ids)
|
|
510
|
+
duplicated_layout_fields = {field_id for field_id in layout_field_ids if layout_field_ids.count(field_id) > 1}
|
|
511
|
+
if duplicated_layout_fields:
|
|
512
|
+
duplicates = ", ".join(sorted(duplicated_layout_fields))
|
|
513
|
+
raise ValueError(f"entity '{entity.entity_id}' form_layout references duplicated fields: {duplicates}")
|
|
514
|
+
for chart in entity.charts:
|
|
515
|
+
self._validate_field_refs(entity.entity_id, chart.chart_id, chart.dimension_field_ids, field_ids, "chart")
|
|
516
|
+
self._validate_field_refs(entity.entity_id, chart.chart_id, chart.indicator_field_ids, field_ids, "chart")
|
|
517
|
+
if entity.workflow:
|
|
518
|
+
node_map = {node.node_id: node for node in entity.workflow.nodes}
|
|
519
|
+
node_ids = set(node_map)
|
|
520
|
+
for node in entity.workflow.nodes:
|
|
521
|
+
if node.parent_node_id and node.parent_node_id not in node_ids:
|
|
522
|
+
raise ValueError(
|
|
523
|
+
f"entity '{entity.entity_id}' workflow node '{node.node_id}' references unknown parent '{node.parent_node_id}'"
|
|
524
|
+
)
|
|
525
|
+
if node.branch_parent_id and node.branch_parent_id not in node_ids:
|
|
526
|
+
raise ValueError(
|
|
527
|
+
f"entity '{entity.entity_id}' workflow node '{node.node_id}' references unknown branch parent '{node.branch_parent_id}'"
|
|
528
|
+
)
|
|
529
|
+
if node.branch_parent_id:
|
|
530
|
+
branch_parent = node_map[node.branch_parent_id]
|
|
531
|
+
if branch_parent.node_type != WorkflowNodeType.branch:
|
|
532
|
+
raise ValueError(
|
|
533
|
+
f"entity '{entity.entity_id}' workflow node '{node.node_id}' must reference a branch node in branch_parent_id"
|
|
534
|
+
)
|
|
535
|
+
for role_ref in node.assignees.get("role_refs", []):
|
|
536
|
+
if role_ref not in role_ids:
|
|
537
|
+
raise ValueError(
|
|
538
|
+
f"entity '{entity.entity_id}' workflow node '{node.node_id}' references unknown role '{role_ref}'"
|
|
539
|
+
)
|
|
540
|
+
for role in self.roles:
|
|
541
|
+
for scope in role.entity_scopes:
|
|
542
|
+
if scope.entity_id not in entity_map:
|
|
543
|
+
raise ValueError(f"role '{role.role_id}' references unknown entity '{scope.entity_id}'")
|
|
544
|
+
valid_field_ids = self._effective_field_ids(entity_map[scope.entity_id])
|
|
545
|
+
self._validate_field_refs(scope.entity_id, role.role_id, scope.visible_field_ids, valid_field_ids, "role")
|
|
546
|
+
for requirement in self.requirements:
|
|
547
|
+
for entity_id in requirement.entity_ids:
|
|
548
|
+
if entity_id not in entity_map:
|
|
549
|
+
raise ValueError(f"requirement '{requirement.requirement_id}' references unknown entity '{entity_id}'")
|
|
550
|
+
for metric in self.success_metrics:
|
|
551
|
+
if metric.entity_id not in entity_map:
|
|
552
|
+
raise ValueError(f"metric '{metric.metric_id}' references unknown entity '{metric.entity_id}'")
|
|
553
|
+
valid_field_ids = self._effective_field_ids(entity_map[metric.entity_id])
|
|
554
|
+
self._validate_special_field_ref(metric.entity_id, "field_id", metric.field_id, valid_field_ids)
|
|
555
|
+
self._validate_special_field_ref(metric.entity_id, "group_by_field_id", metric.group_by_field_id, valid_field_ids)
|
|
556
|
+
self._validate_special_field_ref(metric.entity_id, "time_field_id", metric.time_field_id, valid_field_ids)
|
|
557
|
+
for section in self.portal.sections:
|
|
558
|
+
self._validate_portal_ref(section, entity_map)
|
|
559
|
+
for item in self.navigation.items:
|
|
560
|
+
self._validate_navigation_ref(item, entity_map)
|
|
561
|
+
return self
|
|
562
|
+
|
|
563
|
+
@staticmethod
|
|
564
|
+
def _validate_field_refs(entity_id: str, resource_id: str, refs: list[str], valid_field_ids: set[str], resource_type: str) -> None:
|
|
565
|
+
for field_id in refs:
|
|
566
|
+
if field_id not in valid_field_ids:
|
|
567
|
+
raise ValueError(f"entity '{entity_id}' {resource_type} '{resource_id}' references unknown field '{field_id}'")
|
|
568
|
+
|
|
569
|
+
@staticmethod
|
|
570
|
+
def _validate_special_field_ref(entity_id: str, field_name: str, ref: str | None, valid_field_ids: set[str]) -> None:
|
|
571
|
+
if ref is not None and ref not in valid_field_ids:
|
|
572
|
+
raise ValueError(f"entity '{entity_id}' references unknown field '{ref}' in '{field_name}'")
|
|
573
|
+
|
|
574
|
+
@staticmethod
|
|
575
|
+
def _effective_field_ids(entity: EntitySpec) -> set[str]:
|
|
576
|
+
return {field.field_id for field in entity.fields}
|
|
577
|
+
|
|
578
|
+
@classmethod
|
|
579
|
+
def _validate_portal_ref(cls, section: PortalSectionSpec, entity_map: dict[str, EntitySpec]) -> None:
|
|
580
|
+
if section.entity_id and section.entity_id not in entity_map:
|
|
581
|
+
raise ValueError(f"portal section '{section.section_id}' references unknown entity '{section.entity_id}'")
|
|
582
|
+
if section.chart_id and section.entity_id:
|
|
583
|
+
chart_ids = {chart.chart_id for chart in entity_map[section.entity_id].charts}
|
|
584
|
+
if section.chart_id not in chart_ids:
|
|
585
|
+
raise ValueError(f"portal section '{section.section_id}' references unknown chart '{section.chart_id}'")
|
|
586
|
+
if section.view_id and section.entity_id:
|
|
587
|
+
view_ids = {view.view_id for view in entity_map[section.entity_id].views}
|
|
588
|
+
if section.view_id not in view_ids:
|
|
589
|
+
raise ValueError(f"portal section '{section.section_id}' references unknown view '{section.view_id}'")
|
|
590
|
+
|
|
591
|
+
@classmethod
|
|
592
|
+
def _validate_navigation_ref(cls, item: NavigationItemSpec, entity_map: dict[str, EntitySpec]) -> None:
|
|
593
|
+
if item.entity_id and item.entity_id not in entity_map:
|
|
594
|
+
raise ValueError(f"navigation item '{item.item_id}' references unknown entity '{item.entity_id}'")
|
|
595
|
+
if item.chart_id and item.entity_id:
|
|
596
|
+
chart_ids = {chart.chart_id for chart in entity_map[item.entity_id].charts}
|
|
597
|
+
if item.chart_id not in chart_ids:
|
|
598
|
+
raise ValueError(f"navigation item '{item.item_id}' references unknown chart '{item.chart_id}'")
|
|
599
|
+
if item.view_id and item.entity_id:
|
|
600
|
+
view_ids = {view.view_id for view in entity_map[item.entity_id].views}
|
|
601
|
+
if item.view_id not in view_ids:
|
|
602
|
+
raise ValueError(f"navigation item '{item.item_id}' references unknown view '{item.view_id}'")
|
|
603
|
+
for child in item.children:
|
|
604
|
+
cls._validate_navigation_ref(child, entity_map)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
class AppFlowBuildSpec(StrictModel):
|
|
608
|
+
solution_name: str
|
|
609
|
+
summary: str | None = None
|
|
610
|
+
business_context: BusinessContextSpec = Field(default_factory=BusinessContextSpec)
|
|
611
|
+
package: PackageSpec = Field(default_factory=PackageSpec)
|
|
612
|
+
entities: list[EntitySpec]
|
|
613
|
+
roles: list[RoleSpec] = Field(default_factory=list)
|
|
614
|
+
publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
|
|
615
|
+
preferences: Preferences = Field(default_factory=Preferences)
|
|
616
|
+
assumptions: list[str] = Field(default_factory=list)
|
|
617
|
+
constraints: list[str] = Field(default_factory=list)
|
|
618
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
619
|
+
|
|
620
|
+
@model_validator(mode="after")
|
|
621
|
+
def validate_entities(self) -> "AppFlowBuildSpec":
|
|
622
|
+
if not self.entities:
|
|
623
|
+
raise ValueError("entities must be a non-empty list")
|
|
624
|
+
return self
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class AppEntityBuildSpec(StrictModel):
|
|
628
|
+
entity_id: str
|
|
629
|
+
display_name: str
|
|
630
|
+
plural_name: str | None = None
|
|
631
|
+
kind: EntityKind
|
|
632
|
+
aliases: list[str] = Field(default_factory=list)
|
|
633
|
+
business_purpose: str | None = None
|
|
634
|
+
description: str | None = None
|
|
635
|
+
fields: list[FieldSpec] = Field(default_factory=list)
|
|
636
|
+
form_layout: FormLayoutSpec | None = None
|
|
637
|
+
sample_records: list[SampleRecordSpec] = Field(default_factory=list)
|
|
638
|
+
title_field_id: str | None = None
|
|
639
|
+
status_field_id: str | None = None
|
|
640
|
+
owner_field_id: str | None = None
|
|
641
|
+
start_field_id: str | None = None
|
|
642
|
+
end_field_id: str | None = None
|
|
643
|
+
acceptance_criteria: list[str] = Field(default_factory=list)
|
|
644
|
+
portal_exposure: bool = True
|
|
645
|
+
navigation_exposure: bool = True
|
|
646
|
+
icon: str | None = None
|
|
647
|
+
color: str | None = None
|
|
648
|
+
ordinal: int | None = None
|
|
649
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
650
|
+
|
|
651
|
+
@model_validator(mode="after")
|
|
652
|
+
def validate_entity_shape(self) -> "AppEntityBuildSpec":
|
|
653
|
+
if not self.fields:
|
|
654
|
+
raise ValueError(f"entity '{self.entity_id}' must declare fields")
|
|
655
|
+
field_ids = [field.field_id for field in self.fields]
|
|
656
|
+
if len(field_ids) != len(set(field_ids)):
|
|
657
|
+
raise ValueError(f"entity '{self.entity_id}' has duplicate field_id values")
|
|
658
|
+
field_labels = [field.label for field in self.fields]
|
|
659
|
+
if len(field_labels) != len(set(field_labels)):
|
|
660
|
+
raise ValueError(f"entity '{self.entity_id}' has duplicate field labels")
|
|
661
|
+
field_map = {field.field_id: field for field in self.fields}
|
|
662
|
+
for index, sample_record in enumerate(self.sample_records, start=1):
|
|
663
|
+
record_name = sample_record.record_id or f"sample#{index}"
|
|
664
|
+
for field_id, raw_value in sample_record.values.items():
|
|
665
|
+
field = field_map.get(field_id)
|
|
666
|
+
if field is None:
|
|
667
|
+
raise ValueError(f"entity '{self.entity_id}' sample record '{record_name}' references unknown field '{field_id}'")
|
|
668
|
+
_validate_sample_value(self.entity_id, record_name, field, raw_value)
|
|
669
|
+
return self
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
class AppBuildSpec(StrictModel):
|
|
673
|
+
solution_name: str
|
|
674
|
+
summary: str | None = None
|
|
675
|
+
business_context: BusinessContextSpec = Field(default_factory=BusinessContextSpec)
|
|
676
|
+
package: PackageSpec = Field(default_factory=PackageSpec)
|
|
677
|
+
entities: list[AppEntityBuildSpec]
|
|
678
|
+
roles: list[RoleSpec] = Field(default_factory=list)
|
|
679
|
+
publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
|
|
680
|
+
preferences: Preferences = Field(default_factory=Preferences)
|
|
681
|
+
assumptions: list[str] = Field(default_factory=list)
|
|
682
|
+
constraints: list[str] = Field(default_factory=list)
|
|
683
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
684
|
+
|
|
685
|
+
@model_validator(mode="after")
|
|
686
|
+
def validate_entities(self) -> "AppBuildSpec":
|
|
687
|
+
if not self.entities:
|
|
688
|
+
raise ValueError("entities must be a non-empty list")
|
|
689
|
+
return self
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
class FlowEntityBuildSpec(StrictModel):
|
|
693
|
+
entity_id: str
|
|
694
|
+
workflow: WorkflowSpec | None = None
|
|
695
|
+
lifecycle_stages: list[LifecycleStageSpec] = Field(default_factory=list)
|
|
696
|
+
status_field_id: str | None = None
|
|
697
|
+
owner_field_id: str | None = None
|
|
698
|
+
start_field_id: str | None = None
|
|
699
|
+
end_field_id: str | None = None
|
|
700
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
701
|
+
|
|
702
|
+
@model_validator(mode="after")
|
|
703
|
+
def validate_flow_shape(self) -> "FlowEntityBuildSpec":
|
|
704
|
+
if self.workflow is None and not self.lifecycle_stages:
|
|
705
|
+
raise ValueError(f"entity '{self.entity_id}' must declare workflow or lifecycle_stages")
|
|
706
|
+
if self.workflow and self.workflow.enabled and not self.workflow.nodes and not self.lifecycle_stages:
|
|
707
|
+
raise ValueError(f"entity '{self.entity_id}' workflow.nodes must be a non-empty list when workflow.enabled is true")
|
|
708
|
+
return self
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class FlowBuildSpec(StrictModel):
|
|
712
|
+
solution_name: str | None = None
|
|
713
|
+
entities: list[FlowEntityBuildSpec]
|
|
714
|
+
roles: list[RoleSpec] = Field(default_factory=list)
|
|
715
|
+
publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
|
|
716
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
717
|
+
|
|
718
|
+
@model_validator(mode="after")
|
|
719
|
+
def validate_entities(self) -> "FlowBuildSpec":
|
|
720
|
+
if not self.entities:
|
|
721
|
+
raise ValueError("entities must be a non-empty list")
|
|
722
|
+
return self
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
class EntityViewSpec(StrictModel):
|
|
726
|
+
entity_id: str
|
|
727
|
+
views: list[ViewSpec] = Field(default_factory=list)
|
|
728
|
+
|
|
729
|
+
@model_validator(mode="after")
|
|
730
|
+
def validate_views(self) -> "EntityViewSpec":
|
|
731
|
+
if not self.views:
|
|
732
|
+
raise ValueError("views must be a non-empty list")
|
|
733
|
+
return self
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
class ViewsBuildSpec(StrictModel):
|
|
737
|
+
solution_name: str | None = None
|
|
738
|
+
entities: list[EntityViewSpec]
|
|
739
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
740
|
+
|
|
741
|
+
@model_validator(mode="after")
|
|
742
|
+
def validate_entities(self) -> "ViewsBuildSpec":
|
|
743
|
+
if not self.entities:
|
|
744
|
+
raise ValueError("entities must be a non-empty list")
|
|
745
|
+
return self
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
class EntityAnalyticsSpec(StrictModel):
|
|
749
|
+
entity_id: str
|
|
750
|
+
charts: list[ChartSpec] = Field(default_factory=list)
|
|
751
|
+
|
|
752
|
+
@model_validator(mode="after")
|
|
753
|
+
def validate_charts(self) -> "EntityAnalyticsSpec":
|
|
754
|
+
if not self.charts:
|
|
755
|
+
raise ValueError("charts must be a non-empty list")
|
|
756
|
+
return self
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
class AnalyticsPortalBuildSpec(StrictModel):
|
|
760
|
+
solution_name: str | None = None
|
|
761
|
+
entities: list[EntityAnalyticsSpec] = Field(default_factory=list)
|
|
762
|
+
portal: PortalSpec = Field(default_factory=PortalSpec)
|
|
763
|
+
publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
|
|
764
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
765
|
+
|
|
766
|
+
@model_validator(mode="after")
|
|
767
|
+
def validate_payload(self) -> "AnalyticsPortalBuildSpec":
|
|
768
|
+
if not self.entities and not self.portal.sections:
|
|
769
|
+
raise ValueError("analytics build must declare charts or portal sections")
|
|
770
|
+
return self
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
class NavigationBuildSpec(StrictModel):
|
|
774
|
+
solution_name: str | None = None
|
|
775
|
+
navigation: NavigationSpec = Field(default_factory=NavigationSpec)
|
|
776
|
+
publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
|
|
777
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
778
|
+
|
|
779
|
+
@model_validator(mode="after")
|
|
780
|
+
def validate_navigation(self) -> "NavigationBuildSpec":
|
|
781
|
+
if self.navigation.enabled and not self.navigation.items:
|
|
782
|
+
raise ValueError("navigation.items must be a non-empty list when navigation.enabled is true")
|
|
783
|
+
return self
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
BuildManifest = SolutionSpec
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _validate_sample_value(entity_id: str, record_name: str, field: FieldSpec, raw_value: Any) -> None:
|
|
790
|
+
field_type = field.type
|
|
791
|
+
if raw_value is None:
|
|
792
|
+
return
|
|
793
|
+
if field_type == FieldType.number:
|
|
794
|
+
if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float)):
|
|
795
|
+
raise ValueError(
|
|
796
|
+
f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects an integer-compatible number"
|
|
797
|
+
)
|
|
798
|
+
if isinstance(raw_value, float) and not raw_value.is_integer():
|
|
799
|
+
raise ValueError(
|
|
800
|
+
f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' must be an integer-compatible number; use amount for decimals"
|
|
801
|
+
)
|
|
802
|
+
return
|
|
803
|
+
if field_type == FieldType.amount:
|
|
804
|
+
if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float)):
|
|
805
|
+
raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a numeric amount")
|
|
806
|
+
return
|
|
807
|
+
if field_type == FieldType.member:
|
|
808
|
+
if isinstance(raw_value, int) and raw_value > 0:
|
|
809
|
+
return
|
|
810
|
+
if isinstance(raw_value, dict) and isinstance(raw_value.get("id") or raw_value.get("uid"), int):
|
|
811
|
+
return
|
|
812
|
+
raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a positive member id or member object")
|
|
813
|
+
if field_type == FieldType.boolean:
|
|
814
|
+
if not isinstance(raw_value, bool):
|
|
815
|
+
raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a boolean")
|
|
816
|
+
return
|
|
817
|
+
if field_type == FieldType.single_select:
|
|
818
|
+
if not isinstance(raw_value, str):
|
|
819
|
+
raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a string option")
|
|
820
|
+
if field.options and raw_value not in field.options:
|
|
821
|
+
raise ValueError(
|
|
822
|
+
f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' uses unknown option '{raw_value}'"
|
|
823
|
+
)
|
|
824
|
+
return
|
|
825
|
+
if field_type == FieldType.multi_select:
|
|
826
|
+
items = raw_value if isinstance(raw_value, list) else [raw_value]
|
|
827
|
+
if not all(isinstance(item, str) for item in items):
|
|
828
|
+
raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects string options")
|
|
829
|
+
if field.options:
|
|
830
|
+
unknown = [item for item in items if item not in field.options]
|
|
831
|
+
if unknown:
|
|
832
|
+
raise ValueError(
|
|
833
|
+
f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' uses unknown options {unknown}"
|
|
834
|
+
)
|
|
835
|
+
return
|
|
836
|
+
if field_type in {FieldType.date, FieldType.datetime, FieldType.text, FieldType.long_text, FieldType.phone, FieldType.email, FieldType.address}:
|
|
837
|
+
if not isinstance(raw_value, str):
|
|
838
|
+
raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a string")
|
|
839
|
+
return
|
|
840
|
+
if field_type == FieldType.relation:
|
|
841
|
+
refs = raw_value if isinstance(raw_value, list) else [raw_value]
|
|
842
|
+
if not all(isinstance(item, (str, int)) for item in refs):
|
|
843
|
+
raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects record reference ids")
|
|
844
|
+
return
|
|
845
|
+
if field_type == FieldType.attachment:
|
|
846
|
+
items = raw_value if isinstance(raw_value, list) else [raw_value]
|
|
847
|
+
if not all(isinstance(item, dict) for item in items):
|
|
848
|
+
raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects attachment objects")
|
|
849
|
+
return
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
FieldSpec.model_rebuild()
|
|
853
|
+
NavigationItemSpec.model_rebuild()
|