d365fo-client 0.1.0__py3-none-any.whl
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.
- d365fo_client/__init__.py +305 -0
- d365fo_client/auth.py +93 -0
- d365fo_client/cli.py +700 -0
- d365fo_client/client.py +1454 -0
- d365fo_client/config.py +304 -0
- d365fo_client/crud.py +200 -0
- d365fo_client/exceptions.py +49 -0
- d365fo_client/labels.py +528 -0
- d365fo_client/main.py +502 -0
- d365fo_client/mcp/__init__.py +16 -0
- d365fo_client/mcp/client_manager.py +276 -0
- d365fo_client/mcp/main.py +98 -0
- d365fo_client/mcp/models.py +371 -0
- d365fo_client/mcp/prompts/__init__.py +43 -0
- d365fo_client/mcp/prompts/action_execution.py +480 -0
- d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
- d365fo_client/mcp/resources/__init__.py +15 -0
- d365fo_client/mcp/resources/database_handler.py +555 -0
- d365fo_client/mcp/resources/entity_handler.py +176 -0
- d365fo_client/mcp/resources/environment_handler.py +132 -0
- d365fo_client/mcp/resources/metadata_handler.py +283 -0
- d365fo_client/mcp/resources/query_handler.py +135 -0
- d365fo_client/mcp/server.py +432 -0
- d365fo_client/mcp/tools/__init__.py +17 -0
- d365fo_client/mcp/tools/connection_tools.py +175 -0
- d365fo_client/mcp/tools/crud_tools.py +579 -0
- d365fo_client/mcp/tools/database_tools.py +813 -0
- d365fo_client/mcp/tools/label_tools.py +189 -0
- d365fo_client/mcp/tools/metadata_tools.py +766 -0
- d365fo_client/mcp/tools/profile_tools.py +706 -0
- d365fo_client/metadata_api.py +793 -0
- d365fo_client/metadata_v2/__init__.py +59 -0
- d365fo_client/metadata_v2/cache_v2.py +1372 -0
- d365fo_client/metadata_v2/database_v2.py +585 -0
- d365fo_client/metadata_v2/global_version_manager.py +573 -0
- d365fo_client/metadata_v2/search_engine_v2.py +423 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
- d365fo_client/metadata_v2/version_detector.py +439 -0
- d365fo_client/models.py +862 -0
- d365fo_client/output.py +181 -0
- d365fo_client/profile_manager.py +342 -0
- d365fo_client/profiles.py +178 -0
- d365fo_client/query.py +162 -0
- d365fo_client/session.py +60 -0
- d365fo_client/utils.py +196 -0
- d365fo_client-0.1.0.dist-info/METADATA +1084 -0
- d365fo_client-0.1.0.dist-info/RECORD +51 -0
- d365fo_client-0.1.0.dist-info/WHEEL +5 -0
- d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
- d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
d365fo_client/models.py
ADDED
@@ -0,0 +1,862 @@
|
|
1
|
+
"""Data models and data classes for D365 F&O client."""
|
2
|
+
|
3
|
+
import hashlib
|
4
|
+
import json
|
5
|
+
from dataclasses import dataclass, field
|
6
|
+
from datetime import datetime, timezone
|
7
|
+
from enum import Enum
|
8
|
+
from pathlib import Path
|
9
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
10
|
+
|
11
|
+
from .utils import get_environment_cache_directory
|
12
|
+
|
13
|
+
if TYPE_CHECKING:
|
14
|
+
from typing import ForwardRef
|
15
|
+
|
16
|
+
|
17
|
+
class EntityCategory(Enum):
|
18
|
+
"""D365 F&O Entity Categories"""
|
19
|
+
|
20
|
+
MASTER = "Master"
|
21
|
+
CONFIGURATION = "Configuration"
|
22
|
+
TRANSACTION = "Transaction"
|
23
|
+
REFERENCE = "Reference"
|
24
|
+
DOCUMENT = "Document"
|
25
|
+
PARAMETERS = "Parameters"
|
26
|
+
|
27
|
+
|
28
|
+
class ODataXppType(Enum):
|
29
|
+
"""D365 F&O OData XPP Types"""
|
30
|
+
|
31
|
+
CONTAINER = "Container"
|
32
|
+
DATE = "Date"
|
33
|
+
ENUM = "Enum"
|
34
|
+
GUID = "Guid"
|
35
|
+
INT32 = "Int32"
|
36
|
+
INT64 = "Int64"
|
37
|
+
REAL = "Real"
|
38
|
+
RECORD = "Record"
|
39
|
+
STRING = "String"
|
40
|
+
TIME = "Time"
|
41
|
+
UTC_DATETIME = "UtcDateTime"
|
42
|
+
VOID = "Void"
|
43
|
+
|
44
|
+
|
45
|
+
class ODataBindingKind(Enum):
|
46
|
+
"""D365 F&O Action Binding Types"""
|
47
|
+
|
48
|
+
BOUND_TO_ENTITY_INSTANCE = "BoundToEntityInstance"
|
49
|
+
BOUND_TO_ENTITY_SET = "BoundToEntitySet"
|
50
|
+
UNBOUND = "Unbound"
|
51
|
+
|
52
|
+
|
53
|
+
class SyncStrategy(Enum):
|
54
|
+
"""Metadata synchronization strategies"""
|
55
|
+
|
56
|
+
FULL = "full"
|
57
|
+
INCREMENTAL = "incremental"
|
58
|
+
ENTITIES_ONLY = "entities_only"
|
59
|
+
SHARING_MODE = "sharing_mode"
|
60
|
+
|
61
|
+
|
62
|
+
class Cardinality(Enum):
|
63
|
+
"""Navigation Property Cardinality"""
|
64
|
+
|
65
|
+
SINGLE = "Single"
|
66
|
+
MULTIPLE = "Multiple"
|
67
|
+
|
68
|
+
|
69
|
+
@dataclass
|
70
|
+
class FOClientConfig:
|
71
|
+
"""Configuration for F&O Client"""
|
72
|
+
|
73
|
+
base_url: str
|
74
|
+
client_id: Optional[str] = None
|
75
|
+
client_secret: Optional[str] = None
|
76
|
+
tenant_id: Optional[str] = None
|
77
|
+
use_default_credentials: bool = True
|
78
|
+
verify_ssl: bool = False
|
79
|
+
metadata_cache_dir: str = None
|
80
|
+
timeout: int = 30
|
81
|
+
# Label cache configuration
|
82
|
+
use_label_cache: bool = True
|
83
|
+
label_cache_expiry_minutes: int = 60
|
84
|
+
# Metadata cache configuration
|
85
|
+
enable_metadata_cache: bool = True
|
86
|
+
metadata_sync_interval_minutes: int = 60
|
87
|
+
cache_ttl_seconds: int = 300
|
88
|
+
enable_fts_search: bool = True
|
89
|
+
max_memory_cache_size: int = 1000
|
90
|
+
# Cache-first behavior configuration
|
91
|
+
use_cache_first: bool = True
|
92
|
+
|
93
|
+
def __post_init__(self):
|
94
|
+
"""Post-initialization to set default cache directory if not provided."""
|
95
|
+
if self.metadata_cache_dir is None:
|
96
|
+
cache_dir = (
|
97
|
+
Path.home() / ".d365fo-client"
|
98
|
+
) # get_environment_cache_directory(self.base_url)
|
99
|
+
cache_dir.mkdir(exist_ok=True)
|
100
|
+
self.metadata_cache_dir = str(cache_dir)
|
101
|
+
|
102
|
+
|
103
|
+
@dataclass
|
104
|
+
class QueryOptions:
|
105
|
+
"""OData query options"""
|
106
|
+
|
107
|
+
select: Optional[List[str]] = None
|
108
|
+
filter: Optional[str] = None
|
109
|
+
expand: Optional[List[str]] = None
|
110
|
+
orderby: Optional[List[str]] = None
|
111
|
+
top: Optional[int] = None
|
112
|
+
skip: Optional[int] = None
|
113
|
+
count: bool = False
|
114
|
+
search: Optional[str] = None
|
115
|
+
|
116
|
+
|
117
|
+
@dataclass
|
118
|
+
class LabelInfo:
|
119
|
+
"""Information about a label"""
|
120
|
+
|
121
|
+
id: str
|
122
|
+
language: str
|
123
|
+
value: str
|
124
|
+
|
125
|
+
def to_dict(self) -> Dict[str, str]:
|
126
|
+
return {"id": self.id, "language": self.language, "value": self.value}
|
127
|
+
|
128
|
+
|
129
|
+
@dataclass
|
130
|
+
class ActionParameterTypeInfo:
|
131
|
+
"""Type information for action parameters"""
|
132
|
+
|
133
|
+
type_name: str
|
134
|
+
is_collection: bool = False
|
135
|
+
odata_xpp_type: Optional[str] = None
|
136
|
+
|
137
|
+
def to_dict(self) -> Dict[str, Any]:
|
138
|
+
return {
|
139
|
+
"type_name": self.type_name,
|
140
|
+
"is_collection": self.is_collection,
|
141
|
+
"odata_xpp_type": self.odata_xpp_type,
|
142
|
+
}
|
143
|
+
|
144
|
+
|
145
|
+
@dataclass
|
146
|
+
class ActionParameterInfo:
|
147
|
+
"""Information about an action parameter"""
|
148
|
+
|
149
|
+
name: str
|
150
|
+
type: ActionParameterTypeInfo
|
151
|
+
parameter_order: int = 0
|
152
|
+
|
153
|
+
def to_dict(self) -> Dict[str, Any]:
|
154
|
+
return {
|
155
|
+
"name": self.name,
|
156
|
+
"type": self.type.to_dict(),
|
157
|
+
"parameter_order": self.parameter_order,
|
158
|
+
}
|
159
|
+
|
160
|
+
|
161
|
+
@dataclass
|
162
|
+
class ActionReturnTypeInfo:
|
163
|
+
"""Return type information for actions"""
|
164
|
+
|
165
|
+
type_name: str
|
166
|
+
is_collection: bool = False
|
167
|
+
odata_xpp_type: Optional[str] = None
|
168
|
+
|
169
|
+
def to_dict(self) -> Dict[str, Any]:
|
170
|
+
return {
|
171
|
+
"type_name": self.type_name,
|
172
|
+
"is_collection": self.is_collection,
|
173
|
+
"odata_xpp_type": self.odata_xpp_type,
|
174
|
+
}
|
175
|
+
|
176
|
+
|
177
|
+
@dataclass
|
178
|
+
class PublicEntityActionInfo:
|
179
|
+
"""Detailed action information from PublicEntities endpoint"""
|
180
|
+
|
181
|
+
name: str
|
182
|
+
binding_kind: ODataBindingKind
|
183
|
+
parameters: List[ActionParameterInfo] = None
|
184
|
+
return_type: Optional[ActionReturnTypeInfo] = None
|
185
|
+
field_lookup: Optional[str] = None
|
186
|
+
|
187
|
+
def __post_init__(self):
|
188
|
+
if self.parameters is None:
|
189
|
+
self.parameters = []
|
190
|
+
|
191
|
+
def to_dict(self) -> Dict[str, Any]:
|
192
|
+
return {
|
193
|
+
"name": self.name,
|
194
|
+
"binding_kind": self.binding_kind.value, # Convert enum to string value
|
195
|
+
"parameters": [param.to_dict() for param in self.parameters],
|
196
|
+
"return_type": self.return_type.to_dict() if self.return_type else None,
|
197
|
+
"field_lookup": self.field_lookup,
|
198
|
+
}
|
199
|
+
|
200
|
+
|
201
|
+
@dataclass
|
202
|
+
class DataEntityInfo:
|
203
|
+
"""Information about a data entity from DataEntities endpoint"""
|
204
|
+
|
205
|
+
name: str
|
206
|
+
public_entity_name: str
|
207
|
+
public_collection_name: str
|
208
|
+
label_id: Optional[str] = None
|
209
|
+
label_text: Optional[str] = None
|
210
|
+
data_service_enabled: bool = True
|
211
|
+
data_management_enabled: bool = True
|
212
|
+
entity_category: Optional[EntityCategory] = None
|
213
|
+
is_read_only: bool = False
|
214
|
+
|
215
|
+
def to_dict(self) -> Dict[str, Any]:
|
216
|
+
return {
|
217
|
+
"name": self.name,
|
218
|
+
"public_entity_name": self.public_entity_name,
|
219
|
+
"public_collection_name": self.public_collection_name,
|
220
|
+
"label_id": self.label_id,
|
221
|
+
"label_text": self.label_text,
|
222
|
+
"data_service_enabled": self.data_service_enabled,
|
223
|
+
"data_management_enabled": self.data_management_enabled,
|
224
|
+
"entity_category": self.entity_category,
|
225
|
+
"is_read_only": self.is_read_only,
|
226
|
+
}
|
227
|
+
|
228
|
+
|
229
|
+
@dataclass
|
230
|
+
class PublicEntityPropertyInfo:
|
231
|
+
"""Detailed property information from PublicEntities endpoint"""
|
232
|
+
|
233
|
+
name: str
|
234
|
+
type_name: str
|
235
|
+
data_type: str
|
236
|
+
odata_xpp_type: Optional[str] = None # Map to D365 internal types
|
237
|
+
label_id: Optional[str] = None
|
238
|
+
label_text: Optional[str] = None
|
239
|
+
is_key: bool = False
|
240
|
+
is_mandatory: bool = False
|
241
|
+
configuration_enabled: bool = True
|
242
|
+
allow_edit: bool = True
|
243
|
+
allow_edit_on_create: bool = True
|
244
|
+
is_dimension: bool = False
|
245
|
+
dimension_relation: Optional[str] = None
|
246
|
+
is_dynamic_dimension: bool = False
|
247
|
+
dimension_legal_entity_property: Optional[str] = None
|
248
|
+
dimension_type_property: Optional[str] = None
|
249
|
+
property_order: int = 0
|
250
|
+
|
251
|
+
def to_dict(self) -> Dict[str, Any]:
|
252
|
+
return {
|
253
|
+
"name": self.name,
|
254
|
+
"type_name": self.type_name,
|
255
|
+
"data_type": self.data_type,
|
256
|
+
"odata_xpp_type": self.odata_xpp_type,
|
257
|
+
"label_id": self.label_id,
|
258
|
+
"label_text": self.label_text,
|
259
|
+
"is_key": self.is_key,
|
260
|
+
"is_mandatory": self.is_mandatory,
|
261
|
+
"configuration_enabled": self.configuration_enabled,
|
262
|
+
"allow_edit": self.allow_edit,
|
263
|
+
"allow_edit_on_create": self.allow_edit_on_create,
|
264
|
+
"is_dimension": self.is_dimension,
|
265
|
+
"dimension_relation": self.dimension_relation,
|
266
|
+
"is_dynamic_dimension": self.is_dynamic_dimension,
|
267
|
+
"dimension_legal_entity_property": self.dimension_legal_entity_property,
|
268
|
+
"dimension_type_property": self.dimension_type_property,
|
269
|
+
"property_order": self.property_order,
|
270
|
+
}
|
271
|
+
|
272
|
+
|
273
|
+
@dataclass
|
274
|
+
class PublicEntityInfo:
|
275
|
+
"""Enhanced entity information from PublicEntities endpoint"""
|
276
|
+
|
277
|
+
name: str
|
278
|
+
entity_set_name: str
|
279
|
+
label_id: Optional[str] = None
|
280
|
+
label_text: Optional[str] = None
|
281
|
+
is_read_only: bool = False
|
282
|
+
configuration_enabled: bool = True
|
283
|
+
properties: List[PublicEntityPropertyInfo] = field(default_factory=list)
|
284
|
+
navigation_properties: List["NavigationPropertyInfo"] = field(default_factory=list)
|
285
|
+
property_groups: List["PropertyGroupInfo"] = field(default_factory=list)
|
286
|
+
actions: List["PublicEntityActionInfo"] = field(default_factory=list)
|
287
|
+
|
288
|
+
def to_dict(self) -> Dict[str, Any]:
|
289
|
+
return {
|
290
|
+
"name": self.name,
|
291
|
+
"entity_set_name": self.entity_set_name,
|
292
|
+
"label_id": self.label_id,
|
293
|
+
"label_text": self.label_text,
|
294
|
+
"is_read_only": self.is_read_only,
|
295
|
+
"configuration_enabled": self.configuration_enabled,
|
296
|
+
"properties": [prop.to_dict() for prop in self.properties],
|
297
|
+
"navigation_properties": [
|
298
|
+
nav.to_dict() for nav in self.navigation_properties
|
299
|
+
],
|
300
|
+
"property_groups": [group.to_dict() for group in self.property_groups],
|
301
|
+
"actions": [action.to_dict() for action in self.actions],
|
302
|
+
}
|
303
|
+
|
304
|
+
|
305
|
+
@dataclass
|
306
|
+
class EnumerationMemberInfo:
|
307
|
+
"""Information about an enumeration member"""
|
308
|
+
|
309
|
+
name: str
|
310
|
+
value: int
|
311
|
+
label_id: Optional[str] = None
|
312
|
+
label_text: Optional[str] = None
|
313
|
+
configuration_enabled: bool = True
|
314
|
+
member_order: int = 0
|
315
|
+
|
316
|
+
def to_dict(self) -> Dict[str, Any]:
|
317
|
+
return {
|
318
|
+
"name": self.name,
|
319
|
+
"value": self.value,
|
320
|
+
"label_id": self.label_id,
|
321
|
+
"label_text": self.label_text,
|
322
|
+
"configuration_enabled": self.configuration_enabled,
|
323
|
+
"member_order": self.member_order,
|
324
|
+
}
|
325
|
+
|
326
|
+
|
327
|
+
@dataclass
|
328
|
+
class EnumerationInfo:
|
329
|
+
"""Information about an enumeration from PublicEnumerations endpoint"""
|
330
|
+
|
331
|
+
name: str
|
332
|
+
label_id: Optional[str] = None
|
333
|
+
label_text: Optional[str] = None
|
334
|
+
members: List[EnumerationMemberInfo] = None
|
335
|
+
|
336
|
+
def __post_init__(self):
|
337
|
+
if self.members is None:
|
338
|
+
self.members = []
|
339
|
+
|
340
|
+
def to_dict(self) -> Dict[str, Any]:
|
341
|
+
return {
|
342
|
+
"name": self.name,
|
343
|
+
"label_id": self.label_id,
|
344
|
+
"label_text": self.label_text,
|
345
|
+
"members": [member.to_dict() for member in self.members],
|
346
|
+
}
|
347
|
+
|
348
|
+
|
349
|
+
# Enhanced Complex Type Models
|
350
|
+
|
351
|
+
|
352
|
+
@dataclass
|
353
|
+
class RelationConstraintInfo:
|
354
|
+
"""Base relation constraint information"""
|
355
|
+
|
356
|
+
constraint_type: str = field(init=False) # "Referential"|"Fixed"|"RelatedFixed" - set by __post_init__ in subclasses
|
357
|
+
|
358
|
+
def to_dict(self) -> Dict[str, Any]:
|
359
|
+
return {"constraint_type": self.constraint_type}
|
360
|
+
|
361
|
+
|
362
|
+
@dataclass
|
363
|
+
class ReferentialConstraintInfo(RelationConstraintInfo):
|
364
|
+
"""Referential constraint (foreign key relationship)"""
|
365
|
+
|
366
|
+
property: str
|
367
|
+
referenced_property: str
|
368
|
+
|
369
|
+
def __post_init__(self):
|
370
|
+
self.constraint_type = "Referential"
|
371
|
+
|
372
|
+
def to_dict(self) -> Dict[str, Any]:
|
373
|
+
result = super().to_dict()
|
374
|
+
result.update(
|
375
|
+
{"property": self.property, "referenced_property": self.referenced_property}
|
376
|
+
)
|
377
|
+
return result
|
378
|
+
|
379
|
+
|
380
|
+
@dataclass
|
381
|
+
class FixedConstraintInfo(RelationConstraintInfo):
|
382
|
+
"""Fixed value constraint"""
|
383
|
+
|
384
|
+
property: str
|
385
|
+
value: Optional[int] = None
|
386
|
+
value_str: Optional[str] = None
|
387
|
+
|
388
|
+
def __post_init__(self):
|
389
|
+
self.constraint_type = "Fixed"
|
390
|
+
|
391
|
+
def to_dict(self) -> Dict[str, Any]:
|
392
|
+
result = super().to_dict()
|
393
|
+
result.update(
|
394
|
+
{
|
395
|
+
"property": self.property,
|
396
|
+
"value": self.value,
|
397
|
+
"value_str": self.value_str,
|
398
|
+
}
|
399
|
+
)
|
400
|
+
return result
|
401
|
+
|
402
|
+
|
403
|
+
@dataclass
|
404
|
+
class RelatedFixedConstraintInfo(RelationConstraintInfo):
|
405
|
+
"""Related fixed constraint"""
|
406
|
+
|
407
|
+
related_property: str
|
408
|
+
value: Optional[int] = None
|
409
|
+
value_str: Optional[str] = None
|
410
|
+
|
411
|
+
def __post_init__(self):
|
412
|
+
self.constraint_type = "RelatedFixed"
|
413
|
+
|
414
|
+
def to_dict(self) -> Dict[str, Any]:
|
415
|
+
result = super().to_dict()
|
416
|
+
result.update(
|
417
|
+
{
|
418
|
+
"related_property": self.related_property,
|
419
|
+
"value": self.value,
|
420
|
+
"value_str": self.value_str,
|
421
|
+
}
|
422
|
+
)
|
423
|
+
return result
|
424
|
+
|
425
|
+
|
426
|
+
@dataclass
|
427
|
+
class NavigationPropertyInfo:
|
428
|
+
"""Navigation property with full constraint support"""
|
429
|
+
|
430
|
+
name: str
|
431
|
+
related_entity: str
|
432
|
+
related_relation_name: Optional[str] = None
|
433
|
+
cardinality: Cardinality = Cardinality.SINGLE
|
434
|
+
constraints: List["RelationConstraintInfo"] = field(default_factory=list)
|
435
|
+
|
436
|
+
def to_dict(self) -> Dict[str, Any]:
|
437
|
+
return {
|
438
|
+
"name": self.name,
|
439
|
+
"related_entity": self.related_entity,
|
440
|
+
"related_relation_name": self.related_relation_name,
|
441
|
+
"cardinality": self.cardinality.value, # Convert enum to string value
|
442
|
+
"constraints": [constraint.to_dict() for constraint in self.constraints],
|
443
|
+
}
|
444
|
+
|
445
|
+
|
446
|
+
@dataclass
|
447
|
+
class PropertyGroupInfo:
|
448
|
+
"""Property group information"""
|
449
|
+
|
450
|
+
name: str
|
451
|
+
properties: List[str] = field(default_factory=list)
|
452
|
+
|
453
|
+
def to_dict(self) -> Dict[str, Any]:
|
454
|
+
return {"name": self.name, "properties": self.properties}
|
455
|
+
|
456
|
+
|
457
|
+
@dataclass
|
458
|
+
class ActionTypeInfo:
|
459
|
+
"""Action type with D365-specific type mapping"""
|
460
|
+
|
461
|
+
type_name: str
|
462
|
+
is_collection: bool = False
|
463
|
+
odata_xpp_type: Optional[str] = None
|
464
|
+
|
465
|
+
def to_dict(self) -> Dict[str, Any]:
|
466
|
+
return {
|
467
|
+
"type_name": self.type_name,
|
468
|
+
"is_collection": self.is_collection,
|
469
|
+
"odata_xpp_type": self.odata_xpp_type,
|
470
|
+
}
|
471
|
+
|
472
|
+
|
473
|
+
@dataclass
|
474
|
+
class ActionParameterInfo:
|
475
|
+
"""Enhanced action parameter information"""
|
476
|
+
|
477
|
+
name: str
|
478
|
+
type: "ActionTypeInfo"
|
479
|
+
parameter_order: int = 0
|
480
|
+
|
481
|
+
def to_dict(self) -> Dict[str, Any]:
|
482
|
+
return {
|
483
|
+
"name": self.name,
|
484
|
+
"type": self.type.to_dict(),
|
485
|
+
"parameter_order": self.parameter_order,
|
486
|
+
}
|
487
|
+
|
488
|
+
|
489
|
+
@dataclass
|
490
|
+
class ActionInfo:
|
491
|
+
"""Complete action information with binding support"""
|
492
|
+
|
493
|
+
name: str
|
494
|
+
binding_kind: ODataBindingKind = ODataBindingKind.BOUND_TO_ENTITY_SET
|
495
|
+
entity_name: Optional[str] = None # For bound actions (public entity name)
|
496
|
+
entity_set_name: Optional[str] = (
|
497
|
+
None # For bound actions (entity set name for OData URLs)
|
498
|
+
)
|
499
|
+
parameters: List["ActionParameterInfo"] = field(default_factory=list)
|
500
|
+
return_type: Optional["ActionReturnTypeInfo"] = None
|
501
|
+
field_lookup: Optional[str] = None
|
502
|
+
|
503
|
+
def to_dict(self) -> Dict[str, Any]:
|
504
|
+
return {
|
505
|
+
"name": self.name,
|
506
|
+
"binding_kind": self.binding_kind,
|
507
|
+
"entity_name": self.entity_name,
|
508
|
+
"entity_set_name": self.entity_set_name,
|
509
|
+
"parameters": [param.to_dict() for param in self.parameters],
|
510
|
+
"return_type": self.return_type.to_dict() if self.return_type else None,
|
511
|
+
"field_lookup": self.field_lookup,
|
512
|
+
}
|
513
|
+
|
514
|
+
|
515
|
+
# Cache and Search Models
|
516
|
+
|
517
|
+
|
518
|
+
@dataclass
|
519
|
+
class MetadataVersionInfo:
|
520
|
+
"""Metadata version information"""
|
521
|
+
|
522
|
+
environment_id: int
|
523
|
+
version_hash: str
|
524
|
+
application_version: Optional[str] = None
|
525
|
+
platform_version: Optional[str] = None
|
526
|
+
package_info: Optional[List[Dict[str, Any]]] = None
|
527
|
+
created_at: Optional[datetime] = None
|
528
|
+
is_active: bool = True
|
529
|
+
|
530
|
+
|
531
|
+
@dataclass
|
532
|
+
class SearchQuery:
|
533
|
+
"""Advanced search query parameters"""
|
534
|
+
|
535
|
+
text: str
|
536
|
+
entity_types: Optional[List[str]] = (
|
537
|
+
None # data_entity|public_entity|enumeration|action
|
538
|
+
)
|
539
|
+
filters: Optional[Dict[str, Any]] = None
|
540
|
+
limit: int = 50
|
541
|
+
offset: int = 0
|
542
|
+
use_fulltext: bool = True
|
543
|
+
include_properties: bool = False
|
544
|
+
include_actions: bool = False
|
545
|
+
|
546
|
+
|
547
|
+
@dataclass
|
548
|
+
class SearchResult:
|
549
|
+
"""Individual search result"""
|
550
|
+
|
551
|
+
name: str
|
552
|
+
entity_type: str
|
553
|
+
description: Optional[str] = None
|
554
|
+
relevance: float = 0.0
|
555
|
+
snippet: Optional[str] = None
|
556
|
+
entity_set_name: Optional[str] = None
|
557
|
+
label_text: Optional[str] = None
|
558
|
+
|
559
|
+
def to_dict(self) -> Dict[str, Any]:
|
560
|
+
return {
|
561
|
+
"name": self.name,
|
562
|
+
"entity_type": self.entity_type,
|
563
|
+
"description": self.description,
|
564
|
+
"relevance": self.relevance,
|
565
|
+
"snippet": self.snippet,
|
566
|
+
"entity_set_name": self.entity_set_name,
|
567
|
+
"label_text": self.label_text,
|
568
|
+
}
|
569
|
+
|
570
|
+
|
571
|
+
@dataclass
|
572
|
+
class SearchResults:
|
573
|
+
"""Search results container"""
|
574
|
+
|
575
|
+
results: List[SearchResult]
|
576
|
+
total_count: int = 0
|
577
|
+
query_time_ms: float = 0.0
|
578
|
+
cache_hit: bool = False
|
579
|
+
|
580
|
+
def to_dict(self) -> Dict[str, Any]:
|
581
|
+
return {
|
582
|
+
"results": [result.to_dict() for result in self.results],
|
583
|
+
"total_count": self.total_count,
|
584
|
+
"query_time_ms": self.query_time_ms,
|
585
|
+
"cache_hit": self.cache_hit,
|
586
|
+
}
|
587
|
+
|
588
|
+
|
589
|
+
@dataclass
|
590
|
+
class SyncResult:
|
591
|
+
"""Metadata synchronization result"""
|
592
|
+
|
593
|
+
sync_type: str # full|incremental|skipped
|
594
|
+
entities_synced: int = 0
|
595
|
+
actions_synced: int = 0
|
596
|
+
enumerations_synced: int = 0
|
597
|
+
labels_synced: int = 0
|
598
|
+
duration_ms: float = 0.0
|
599
|
+
success: bool = True
|
600
|
+
errors: List[str] = field(default_factory=list)
|
601
|
+
reason: Optional[str] = None
|
602
|
+
|
603
|
+
|
604
|
+
# ============================================================================
|
605
|
+
# Enhanced V2 Models for Advanced Metadata Caching
|
606
|
+
# ============================================================================
|
607
|
+
|
608
|
+
|
609
|
+
@dataclass
|
610
|
+
class ModuleVersionInfo:
|
611
|
+
"""Information about installed D365 module based on GetInstalledModules response"""
|
612
|
+
|
613
|
+
name: str # Module name (e.g., "AccountsPayableMobile")
|
614
|
+
version: str # Version string (e.g., "10.34.2105.34092")
|
615
|
+
module_id: str # Module identifier (e.g., "AccountsPayableMobile")
|
616
|
+
publisher: str # Publisher (e.g., "Microsoft Corporation")
|
617
|
+
display_name: str # Human-readable name (e.g., "Accounts Payable Mobile")
|
618
|
+
|
619
|
+
@classmethod
|
620
|
+
def parse_from_string(cls, module_string: str) -> "ModuleVersionInfo":
|
621
|
+
"""Parse module info from GetInstalledModules string format
|
622
|
+
|
623
|
+
Args:
|
624
|
+
module_string: String in format "Name: X | Version: Y | Module: Z | Publisher: W | DisplayName: V"
|
625
|
+
|
626
|
+
Returns:
|
627
|
+
ModuleVersionInfo instance
|
628
|
+
|
629
|
+
Raises:
|
630
|
+
ValueError: If string format is invalid
|
631
|
+
"""
|
632
|
+
try:
|
633
|
+
parts = module_string.split(" | ")
|
634
|
+
if len(parts) != 5:
|
635
|
+
raise ValueError(
|
636
|
+
f"Invalid module string format: expected 5 parts, got {len(parts)}"
|
637
|
+
)
|
638
|
+
|
639
|
+
name = parts[0].replace("Name: ", "")
|
640
|
+
version = parts[1].replace("Version: ", "")
|
641
|
+
module_id = parts[2].replace("Module: ", "")
|
642
|
+
publisher = parts[3].replace("Publisher: ", "")
|
643
|
+
display_name = parts[4].replace("DisplayName: ", "")
|
644
|
+
|
645
|
+
# Validate that all parts are non-empty
|
646
|
+
if not all([name, version, module_id, publisher, display_name]):
|
647
|
+
raise ValueError("All module string parts must be non-empty")
|
648
|
+
|
649
|
+
return cls(
|
650
|
+
name=name,
|
651
|
+
version=version,
|
652
|
+
module_id=module_id,
|
653
|
+
publisher=publisher,
|
654
|
+
display_name=display_name,
|
655
|
+
)
|
656
|
+
except Exception as e:
|
657
|
+
raise ValueError(f"Failed to parse module string '{module_string}': {e}")
|
658
|
+
|
659
|
+
def to_dict(self) -> Dict[str, str]:
|
660
|
+
"""Convert to dictionary for JSON serialization"""
|
661
|
+
return {
|
662
|
+
"name": self.name,
|
663
|
+
"version": self.version,
|
664
|
+
"module_id": self.module_id,
|
665
|
+
"publisher": self.publisher,
|
666
|
+
"display_name": self.display_name,
|
667
|
+
}
|
668
|
+
|
669
|
+
|
670
|
+
@dataclass
|
671
|
+
class EnvironmentVersionInfo:
|
672
|
+
"""Enhanced environment version with precise module tracking"""
|
673
|
+
|
674
|
+
environment_id: int
|
675
|
+
version_hash: str # Fast hash based on all module versions
|
676
|
+
modules_hash: str # Hash of sorted module list for deduplication
|
677
|
+
application_version: Optional[str] = None # Fallback version info
|
678
|
+
platform_version: Optional[str] = None # Fallback version info
|
679
|
+
modules: List[ModuleVersionInfo] = field(default_factory=list)
|
680
|
+
computed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
681
|
+
is_active: bool = True
|
682
|
+
|
683
|
+
def __post_init__(self):
|
684
|
+
"""Ensure version hashes are computed if not provided"""
|
685
|
+
if not self.modules_hash and self.modules:
|
686
|
+
self.modules_hash = self._compute_modules_hash()
|
687
|
+
if not self.version_hash:
|
688
|
+
self.version_hash = self.modules_hash[
|
689
|
+
:16
|
690
|
+
] # Use first 16 chars for compatibility
|
691
|
+
|
692
|
+
def _compute_modules_hash(self) -> str:
|
693
|
+
"""Compute hash based on sorted module versions for consistent deduplication"""
|
694
|
+
if not self.modules:
|
695
|
+
return hashlib.sha256("empty".encode()).hexdigest()
|
696
|
+
|
697
|
+
# Sort modules by module_id for consistent hashing
|
698
|
+
sorted_modules = sorted(self.modules, key=lambda m: m.module_id)
|
699
|
+
|
700
|
+
# Create hash input from essential version data
|
701
|
+
hash_data = []
|
702
|
+
for module in sorted_modules:
|
703
|
+
hash_data.append(f"{module.module_id}:{module.version}")
|
704
|
+
|
705
|
+
hash_input = "|".join(hash_data)
|
706
|
+
return hashlib.sha256(hash_input.encode()).hexdigest()
|
707
|
+
|
708
|
+
def to_dict(self) -> Dict[str, Any]:
|
709
|
+
"""Convert to dictionary for JSON serialization"""
|
710
|
+
return {
|
711
|
+
"environment_id": self.environment_id,
|
712
|
+
"version_hash": self.version_hash,
|
713
|
+
"modules_hash": self.modules_hash,
|
714
|
+
"application_version": self.application_version,
|
715
|
+
"platform_version": self.platform_version,
|
716
|
+
"modules": [module.to_dict() for module in self.modules],
|
717
|
+
"computed_at": self.computed_at.isoformat(),
|
718
|
+
"is_active": self.is_active,
|
719
|
+
}
|
720
|
+
|
721
|
+
|
722
|
+
@dataclass
|
723
|
+
class GlobalVersionInfo:
|
724
|
+
"""Global version registry for cross-environment sharing"""
|
725
|
+
|
726
|
+
id: int
|
727
|
+
version_hash: str
|
728
|
+
modules_hash: str
|
729
|
+
first_seen_at: datetime
|
730
|
+
last_used_at: datetime
|
731
|
+
reference_count: int
|
732
|
+
sample_modules: List[ModuleVersionInfo] = field(
|
733
|
+
default_factory=list
|
734
|
+
) # Sample for debugging
|
735
|
+
|
736
|
+
def to_dict(self) -> Dict[str, Any]:
|
737
|
+
"""Convert to dictionary for JSON serialization"""
|
738
|
+
return {
|
739
|
+
"id": self.id,
|
740
|
+
"version_hash": self.version_hash,
|
741
|
+
"modules_hash": self.modules_hash,
|
742
|
+
"first_seen_at": self.first_seen_at.isoformat(),
|
743
|
+
"last_used_at": self.last_used_at.isoformat(),
|
744
|
+
"reference_count": self.reference_count,
|
745
|
+
"sample_modules": [module.to_dict() for module in self.sample_modules],
|
746
|
+
}
|
747
|
+
|
748
|
+
|
749
|
+
@dataclass
|
750
|
+
class CacheStatistics:
|
751
|
+
"""Enhanced cache statistics with version sharing metrics"""
|
752
|
+
|
753
|
+
total_environments: int
|
754
|
+
unique_versions: int
|
755
|
+
shared_versions: int
|
756
|
+
cache_hit_ratio: float
|
757
|
+
storage_efficiency: float # Ratio of shared vs duplicate storage
|
758
|
+
last_sync_times: Dict[str, datetime]
|
759
|
+
version_distribution: Dict[str, int] # version_hash -> environment_count
|
760
|
+
|
761
|
+
def to_dict(self) -> Dict[str, Any]:
|
762
|
+
"""Convert to dictionary for JSON serialization"""
|
763
|
+
return {
|
764
|
+
"total_environments": self.total_environments,
|
765
|
+
"unique_versions": self.unique_versions,
|
766
|
+
"shared_versions": self.shared_versions,
|
767
|
+
"cache_hit_ratio": self.cache_hit_ratio,
|
768
|
+
"storage_efficiency": self.storage_efficiency,
|
769
|
+
"last_sync_times": {
|
770
|
+
k: v.isoformat() for k, v in self.last_sync_times.items()
|
771
|
+
},
|
772
|
+
"version_distribution": self.version_distribution,
|
773
|
+
}
|
774
|
+
|
775
|
+
|
776
|
+
@dataclass
|
777
|
+
class VersionDetectionResult:
|
778
|
+
"""Result of version detection operation"""
|
779
|
+
|
780
|
+
success: bool
|
781
|
+
version_info: Optional[EnvironmentVersionInfo] = None
|
782
|
+
error_message: Optional[str] = None
|
783
|
+
detection_time_ms: float = 0.0
|
784
|
+
modules_count: int = 0
|
785
|
+
cache_hit: bool = False
|
786
|
+
|
787
|
+
def to_dict(self) -> Dict[str, Any]:
|
788
|
+
"""Convert to dictionary for JSON serialization"""
|
789
|
+
return {
|
790
|
+
"success": self.success,
|
791
|
+
"version_info": self.version_info.to_dict() if self.version_info else None,
|
792
|
+
"error_message": self.error_message,
|
793
|
+
"detection_time_ms": self.detection_time_ms,
|
794
|
+
"modules_count": self.modules_count,
|
795
|
+
"cache_hit": self.cache_hit,
|
796
|
+
}
|
797
|
+
|
798
|
+
|
799
|
+
@dataclass
|
800
|
+
class SyncResultV2:
|
801
|
+
"""Enhanced synchronization result for v2 with sharing metrics"""
|
802
|
+
|
803
|
+
sync_type: str # full|incremental|linked|skipped|failed
|
804
|
+
entities_synced: int = 0
|
805
|
+
actions_synced: int = 0
|
806
|
+
enumerations_synced: int = 0
|
807
|
+
labels_synced: int = 0
|
808
|
+
duration_ms: float = 0.0
|
809
|
+
success: bool = True
|
810
|
+
errors: List[str] = field(default_factory=list)
|
811
|
+
reason: Optional[str] = None
|
812
|
+
# V2 specific fields
|
813
|
+
global_version_id: Optional[int] = None
|
814
|
+
was_shared: bool = False
|
815
|
+
reference_count: int = 1
|
816
|
+
storage_saved_bytes: int = 0
|
817
|
+
|
818
|
+
def to_dict(self) -> Dict[str, Any]:
|
819
|
+
"""Convert to dictionary for JSON serialization"""
|
820
|
+
return {
|
821
|
+
"sync_type": self.sync_type,
|
822
|
+
"entities_synced": self.entities_synced,
|
823
|
+
"actions_synced": self.actions_synced,
|
824
|
+
"enumerations_synced": self.enumerations_synced,
|
825
|
+
"labels_synced": self.labels_synced,
|
826
|
+
"duration_ms": self.duration_ms,
|
827
|
+
"success": self.success,
|
828
|
+
"errors": self.errors,
|
829
|
+
"reason": self.reason,
|
830
|
+
"global_version_id": self.global_version_id,
|
831
|
+
"was_shared": self.was_shared,
|
832
|
+
"reference_count": self.reference_count,
|
833
|
+
"storage_saved_bytes": self.storage_saved_bytes,
|
834
|
+
}
|
835
|
+
|
836
|
+
|
837
|
+
@dataclass
|
838
|
+
class SyncProgress:
|
839
|
+
"""Sync progress tracking"""
|
840
|
+
|
841
|
+
global_version_id: int
|
842
|
+
strategy: SyncStrategy
|
843
|
+
phase: str
|
844
|
+
total_steps: int
|
845
|
+
completed_steps: int
|
846
|
+
current_operation: str
|
847
|
+
start_time: datetime
|
848
|
+
estimated_completion: Optional[datetime] = None
|
849
|
+
error: Optional[str] = None
|
850
|
+
|
851
|
+
|
852
|
+
@dataclass
|
853
|
+
class SyncResult:
|
854
|
+
"""Sync operation result"""
|
855
|
+
|
856
|
+
success: bool
|
857
|
+
error: Optional[str]
|
858
|
+
duration_ms: int
|
859
|
+
entity_count: int
|
860
|
+
action_count: int
|
861
|
+
enumeration_count: int
|
862
|
+
label_count: int
|