d365fo-client 0.2.2__py3-none-any.whl → 0.2.4__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/auth.py +48 -9
- d365fo_client/client.py +40 -20
- d365fo_client/credential_sources.py +431 -0
- d365fo_client/mcp/client_manager.py +8 -0
- d365fo_client/mcp/main.py +39 -17
- d365fo_client/mcp/server.py +69 -22
- d365fo_client/mcp/tools/__init__.py +2 -0
- d365fo_client/mcp/tools/profile_tools.py +261 -2
- d365fo_client/mcp/tools/sync_tools.py +503 -0
- d365fo_client/metadata_api.py +67 -0
- d365fo_client/metadata_v2/cache_v2.py +11 -9
- d365fo_client/metadata_v2/global_version_manager.py +2 -4
- d365fo_client/metadata_v2/sync_manager_v2.py +1 -1
- d365fo_client/metadata_v2/sync_session_manager.py +1043 -0
- d365fo_client/models.py +22 -3
- d365fo_client/profile_manager.py +7 -1
- d365fo_client/profiles.py +28 -1
- d365fo_client/sync_models.py +181 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/METADATA +1011 -784
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/RECORD +24 -20
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,503 @@
|
|
1
|
+
"""Sync management tools for MCP server."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
import logging
|
5
|
+
from typing import List
|
6
|
+
|
7
|
+
from mcp import Tool
|
8
|
+
from mcp.types import TextContent
|
9
|
+
|
10
|
+
from ..client_manager import D365FOClientManager
|
11
|
+
from ...models import SyncStrategy
|
12
|
+
from ...sync_models import SyncStatus
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
class SyncTools:
|
18
|
+
"""Sync management tools for the MCP server."""
|
19
|
+
|
20
|
+
def __init__(self, client_manager: D365FOClientManager):
|
21
|
+
"""Initialize sync tools.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
client_manager: D365FO client manager instance
|
25
|
+
"""
|
26
|
+
self.client_manager = client_manager
|
27
|
+
|
28
|
+
def get_tools(self) -> List[Tool]:
|
29
|
+
"""Get list of sync tools.
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
List of Tool definitions
|
33
|
+
"""
|
34
|
+
return [
|
35
|
+
self._get_start_sync_tool(),
|
36
|
+
self._get_sync_progress_tool(),
|
37
|
+
self._get_cancel_sync_tool(),
|
38
|
+
self._get_list_sync_sessions_tool(),
|
39
|
+
self._get_sync_history_tool(),
|
40
|
+
]
|
41
|
+
|
42
|
+
def _get_start_sync_tool(self) -> Tool:
|
43
|
+
"""Get start sync tool definition."""
|
44
|
+
return Tool(
|
45
|
+
name="d365fo_start_sync",
|
46
|
+
description="Start a metadata synchronization session and return a session ID for tracking progress. This downloads and caches metadata from D365 F&O including entities, schemas, enumerations, and labels.",
|
47
|
+
inputSchema={
|
48
|
+
"type": "object",
|
49
|
+
"properties": {
|
50
|
+
"strategy": {
|
51
|
+
"type": "string",
|
52
|
+
"enum": ["full", "entities_only", "labels_only", "full_without_labels", "sharing_mode", "incremental"],
|
53
|
+
"description": "Sync strategy to use. 'full' downloads all metadata, 'entities_only' downloads just entities for quick refresh, 'labels_only' downloads only labels, 'full_without_labels' downloads all metadata except labels, 'sharing_mode' copies from compatible versions, 'incremental' updates only changes (fallback to full).",
|
54
|
+
"default": "full_without_labels"
|
55
|
+
},
|
56
|
+
"global_version_id": {
|
57
|
+
"type": "integer",
|
58
|
+
"description": "Specific global version ID to sync. If not provided, will detect current version automatically."
|
59
|
+
},
|
60
|
+
"profile": {
|
61
|
+
"type": "string",
|
62
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)"
|
63
|
+
}
|
64
|
+
},
|
65
|
+
"additionalProperties": False,
|
66
|
+
},
|
67
|
+
)
|
68
|
+
|
69
|
+
def _get_sync_progress_tool(self) -> Tool:
|
70
|
+
"""Get sync progress tool definition."""
|
71
|
+
return Tool(
|
72
|
+
name="d365fo_get_sync_progress",
|
73
|
+
description="Get detailed progress information for a specific sync session including current phase, completion percentage, items processed, and estimated time remaining.",
|
74
|
+
inputSchema={
|
75
|
+
"type": "object",
|
76
|
+
"properties": {
|
77
|
+
"session_id": {
|
78
|
+
"type": "string",
|
79
|
+
"description": "Session ID of the sync operation to check progress for"
|
80
|
+
},
|
81
|
+
"profile": {
|
82
|
+
"type": "string",
|
83
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)"
|
84
|
+
}
|
85
|
+
},
|
86
|
+
"required": ["session_id"],
|
87
|
+
"additionalProperties": False,
|
88
|
+
},
|
89
|
+
)
|
90
|
+
|
91
|
+
def _get_cancel_sync_tool(self) -> Tool:
|
92
|
+
"""Get cancel sync tool definition."""
|
93
|
+
return Tool(
|
94
|
+
name="d365fo_cancel_sync",
|
95
|
+
description="Cancel a running sync session. Only sessions that are currently running and marked as cancellable can be cancelled.",
|
96
|
+
inputSchema={
|
97
|
+
"type": "object",
|
98
|
+
"properties": {
|
99
|
+
"session_id": {
|
100
|
+
"type": "string",
|
101
|
+
"description": "Session ID of the sync operation to cancel"
|
102
|
+
},
|
103
|
+
"profile": {
|
104
|
+
"type": "string",
|
105
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)"
|
106
|
+
}
|
107
|
+
},
|
108
|
+
"required": ["session_id"],
|
109
|
+
"additionalProperties": False,
|
110
|
+
},
|
111
|
+
)
|
112
|
+
|
113
|
+
def _get_list_sync_sessions_tool(self) -> Tool:
|
114
|
+
"""Get list sync sessions tool definition."""
|
115
|
+
return Tool(
|
116
|
+
name="d365fo_list_sync_sessions",
|
117
|
+
description="Get a list of all currently active sync sessions with their status, progress, and details.",
|
118
|
+
inputSchema={
|
119
|
+
"type": "object",
|
120
|
+
"properties": {
|
121
|
+
"profile": {
|
122
|
+
"type": "string",
|
123
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)"
|
124
|
+
}
|
125
|
+
},
|
126
|
+
"additionalProperties": False,
|
127
|
+
},
|
128
|
+
)
|
129
|
+
|
130
|
+
def _get_sync_history_tool(self) -> Tool:
|
131
|
+
"""Get sync history tool definition."""
|
132
|
+
return Tool(
|
133
|
+
name="d365fo_get_sync_history",
|
134
|
+
description="Get the history of completed sync sessions including success/failure status, duration, and statistics.",
|
135
|
+
inputSchema={
|
136
|
+
"type": "object",
|
137
|
+
"properties": {
|
138
|
+
"limit": {
|
139
|
+
"type": "integer",
|
140
|
+
"description": "Maximum number of historical sessions to return (default: 20, max: 100)",
|
141
|
+
"minimum": 1,
|
142
|
+
"maximum": 100,
|
143
|
+
"default": 20
|
144
|
+
},
|
145
|
+
"profile": {
|
146
|
+
"type": "string",
|
147
|
+
"description": "Configuration profile to use (optional - uses default profile if not specified)"
|
148
|
+
}
|
149
|
+
},
|
150
|
+
"additionalProperties": False,
|
151
|
+
},
|
152
|
+
)
|
153
|
+
|
154
|
+
async def execute_start_sync(self, arguments: dict) -> List[TextContent]:
|
155
|
+
"""Execute start sync operation."""
|
156
|
+
try:
|
157
|
+
profile = arguments.get("profile", "default")
|
158
|
+
client = await self.client_manager.get_client(profile)
|
159
|
+
|
160
|
+
# Initialize metadata first to ensure all components are available
|
161
|
+
await client.initialize_metadata()
|
162
|
+
|
163
|
+
if not hasattr(client, 'sync_session_manager'):
|
164
|
+
# Fall back to the original sync manager if session manager not available
|
165
|
+
return await self._fallback_start_sync(client, arguments)
|
166
|
+
|
167
|
+
strategy_str = arguments.get("strategy", "full_without_labels")
|
168
|
+
strategy = SyncStrategy(strategy_str)
|
169
|
+
global_version_id = arguments.get("global_version_id")
|
170
|
+
sync_needed = True
|
171
|
+
session_id = None
|
172
|
+
|
173
|
+
# Auto-detect version if not provided
|
174
|
+
if global_version_id is None:
|
175
|
+
sync_needed, detected_version_id = await client.metadata_cache.check_version_and_sync()
|
176
|
+
if detected_version_id is None:
|
177
|
+
raise ValueError("Could not detect global version ID")
|
178
|
+
global_version_id = detected_version_id
|
179
|
+
|
180
|
+
if sync_needed or strategy == SyncStrategy.LABELS_ONLY:
|
181
|
+
# Start sync session
|
182
|
+
session_id = await client.sync_session_manager.start_sync_session(
|
183
|
+
global_version_id=global_version_id,
|
184
|
+
strategy=strategy,
|
185
|
+
initiated_by="mcp"
|
186
|
+
)
|
187
|
+
|
188
|
+
response = {
|
189
|
+
"success": True,
|
190
|
+
"session_id": session_id if sync_needed or strategy == SyncStrategy.LABELS_ONLY else None,
|
191
|
+
"global_version_id": global_version_id,
|
192
|
+
"strategy": strategy_str,
|
193
|
+
"message": f"Sync session {session_id} started successfully" if sync_needed or strategy == SyncStrategy.LABELS_ONLY else f"Metadata already up to date at version {global_version_id}, no sync needed",
|
194
|
+
"instructions": f"Use d365fo_get_sync_progress with session_id '{session_id}' to monitor progress" if sync_needed or strategy == SyncStrategy.LABELS_ONLY else None
|
195
|
+
}
|
196
|
+
|
197
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
198
|
+
|
199
|
+
except Exception as e:
|
200
|
+
logger.error(f"Start sync failed: {e}")
|
201
|
+
error_response = {
|
202
|
+
"success": False,
|
203
|
+
"error": str(e),
|
204
|
+
"tool": "d365fo_start_sync",
|
205
|
+
"arguments": arguments,
|
206
|
+
}
|
207
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
208
|
+
|
209
|
+
async def execute_get_sync_progress(self, arguments: dict) -> List[TextContent]:
|
210
|
+
"""Execute get sync progress operation."""
|
211
|
+
try:
|
212
|
+
profile = arguments.get("profile", "default")
|
213
|
+
client = await self.client_manager.get_client(profile)
|
214
|
+
session_id = arguments["session_id"]
|
215
|
+
|
216
|
+
# Initialize metadata to ensure sync session manager is available
|
217
|
+
await client.initialize_metadata()
|
218
|
+
|
219
|
+
if not hasattr(client, 'sync_session_manager'):
|
220
|
+
# Fall back to basic progress check
|
221
|
+
return await self._fallback_get_progress(client, session_id)
|
222
|
+
|
223
|
+
# Get session details
|
224
|
+
session = client.sync_session_manager.get_sync_session(session_id)
|
225
|
+
|
226
|
+
if not session:
|
227
|
+
error_response = {
|
228
|
+
"success": False,
|
229
|
+
"error": f"Session {session_id} not found",
|
230
|
+
"session_id": session_id
|
231
|
+
}
|
232
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
233
|
+
|
234
|
+
# Convert session to detailed progress response
|
235
|
+
response = {
|
236
|
+
"success": True,
|
237
|
+
"session": session.to_dict(),
|
238
|
+
"summary": {
|
239
|
+
"status": session.status,
|
240
|
+
"progress_percent": round(session.progress_percent, 1),
|
241
|
+
"current_phase": session.current_phase,
|
242
|
+
"current_activity": session.current_activity,
|
243
|
+
"estimated_remaining_seconds": session.estimate_remaining_time(),
|
244
|
+
"is_running": session.status == SyncStatus.RUNNING,
|
245
|
+
"can_cancel": session.can_cancel
|
246
|
+
}
|
247
|
+
}
|
248
|
+
|
249
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
250
|
+
|
251
|
+
except Exception as e:
|
252
|
+
logger.error(f"Get sync progress failed: {e}")
|
253
|
+
error_response = {
|
254
|
+
"success": False,
|
255
|
+
"error": str(e),
|
256
|
+
"tool": "d365fo_get_sync_progress",
|
257
|
+
"arguments": arguments,
|
258
|
+
}
|
259
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
260
|
+
|
261
|
+
async def execute_cancel_sync(self, arguments: dict) -> List[TextContent]:
|
262
|
+
"""Execute cancel sync operation."""
|
263
|
+
try:
|
264
|
+
profile = arguments.get("profile", "default")
|
265
|
+
client = await self.client_manager.get_client(profile)
|
266
|
+
session_id = arguments["session_id"]
|
267
|
+
|
268
|
+
# Initialize metadata to ensure sync session manager is available
|
269
|
+
await client.initialize_metadata()
|
270
|
+
|
271
|
+
if not hasattr(client, 'sync_session_manager'):
|
272
|
+
error_response = {
|
273
|
+
"success": False,
|
274
|
+
"error": "Sync session management not available in this client version",
|
275
|
+
"session_id": session_id
|
276
|
+
}
|
277
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
278
|
+
|
279
|
+
# Cancel session
|
280
|
+
cancelled = await client.sync_session_manager.cancel_sync_session(session_id)
|
281
|
+
|
282
|
+
response = {
|
283
|
+
"success": cancelled,
|
284
|
+
"session_id": session_id,
|
285
|
+
"message": f"Session {session_id} {'cancelled' if cancelled else 'could not be cancelled'}",
|
286
|
+
"details": "Session may not be cancellable if already completed or failed"
|
287
|
+
}
|
288
|
+
|
289
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
290
|
+
|
291
|
+
except Exception as e:
|
292
|
+
logger.error(f"Cancel sync failed: {e}")
|
293
|
+
error_response = {
|
294
|
+
"success": False,
|
295
|
+
"error": str(e),
|
296
|
+
"tool": "d365fo_cancel_sync",
|
297
|
+
"arguments": arguments,
|
298
|
+
}
|
299
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
300
|
+
|
301
|
+
async def execute_list_sync_sessions(self, arguments: dict) -> List[TextContent]:
|
302
|
+
"""Execute list sync sessions operation."""
|
303
|
+
try:
|
304
|
+
profile = arguments.get("profile", "default")
|
305
|
+
client = await self.client_manager.get_client(profile)
|
306
|
+
|
307
|
+
# Initialize metadata to ensure sync session manager is available
|
308
|
+
await client.initialize_metadata()
|
309
|
+
|
310
|
+
if not hasattr(client, 'sync_session_manager'):
|
311
|
+
# Check if legacy sync manager is running
|
312
|
+
return await self._fallback_list_sessions(client)
|
313
|
+
|
314
|
+
# Get active sessions
|
315
|
+
active_sessions = client.sync_session_manager.get_active_sessions()
|
316
|
+
|
317
|
+
response = {
|
318
|
+
"success": True,
|
319
|
+
"active_sessions": [session.to_dict() for session in active_sessions],
|
320
|
+
"total_count": len(active_sessions),
|
321
|
+
"running_count": len([s for s in active_sessions if s.status == SyncStatus.RUNNING]),
|
322
|
+
"summary": {
|
323
|
+
"has_running_sessions": any(s.status == SyncStatus.RUNNING for s in active_sessions),
|
324
|
+
"latest_session": active_sessions[-1].to_dict() if active_sessions else None
|
325
|
+
}
|
326
|
+
}
|
327
|
+
|
328
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
329
|
+
|
330
|
+
except Exception as e:
|
331
|
+
logger.error(f"List sync sessions failed: {e}")
|
332
|
+
error_response = {
|
333
|
+
"success": False,
|
334
|
+
"error": str(e),
|
335
|
+
"tool": "d365fo_list_sync_sessions",
|
336
|
+
"arguments": arguments,
|
337
|
+
}
|
338
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
339
|
+
|
340
|
+
async def execute_get_sync_history(self, arguments: dict) -> List[TextContent]:
|
341
|
+
"""Execute get sync history operation."""
|
342
|
+
try:
|
343
|
+
profile = arguments.get("profile", "default")
|
344
|
+
client = await self.client_manager.get_client(profile)
|
345
|
+
limit = arguments.get("limit", 20)
|
346
|
+
|
347
|
+
# Initialize metadata to ensure sync session manager is available
|
348
|
+
await client.initialize_metadata()
|
349
|
+
|
350
|
+
if not hasattr(client, 'sync_session_manager'):
|
351
|
+
error_response = {
|
352
|
+
"success": False,
|
353
|
+
"error": "Sync session management not available in this client version",
|
354
|
+
"message": "Upgrade to session-based sync manager to access history"
|
355
|
+
}
|
356
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
357
|
+
|
358
|
+
# Get session history
|
359
|
+
history = client.sync_session_manager.get_session_history(limit)
|
360
|
+
|
361
|
+
response = {
|
362
|
+
"success": True,
|
363
|
+
"history": [session.to_dict() for session in history],
|
364
|
+
"total_count": len(history),
|
365
|
+
"summary": {
|
366
|
+
"successful_syncs": len([s for s in history if s.status == SyncStatus.COMPLETED]),
|
367
|
+
"failed_syncs": len([s for s in history if s.status == SyncStatus.FAILED]),
|
368
|
+
"cancelled_syncs": len([s for s in history if s.status == SyncStatus.CANCELLED]),
|
369
|
+
"average_duration": sum(s.duration_seconds for s in history if s.duration_seconds) / len([s for s in history if s.duration_seconds]) if history else 0
|
370
|
+
}
|
371
|
+
}
|
372
|
+
|
373
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
374
|
+
|
375
|
+
except Exception as e:
|
376
|
+
logger.error(f"Get sync history failed: {e}")
|
377
|
+
error_response = {
|
378
|
+
"success": False,
|
379
|
+
"error": str(e),
|
380
|
+
"tool": "d365fo_get_sync_history",
|
381
|
+
"arguments": arguments,
|
382
|
+
}
|
383
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
384
|
+
|
385
|
+
async def _fallback_start_sync(self, client, arguments: dict) -> List[TextContent]:
|
386
|
+
"""Fallback to original sync manager for starting sync."""
|
387
|
+
strategy_str = arguments.get("strategy", "full")
|
388
|
+
strategy = SyncStrategy(strategy_str)
|
389
|
+
global_version_id = arguments.get("global_version_id")
|
390
|
+
|
391
|
+
# Auto-detect version if not provided
|
392
|
+
if global_version_id is None:
|
393
|
+
sync_needed, detected_version_id = await client.metadata_cache.check_version_and_sync()
|
394
|
+
if detected_version_id is None:
|
395
|
+
raise ValueError("Could not detect global version ID")
|
396
|
+
global_version_id = detected_version_id
|
397
|
+
|
398
|
+
# Check if sync already running
|
399
|
+
if hasattr(client, 'smart_sync_manager') and client.smart_sync_manager.is_syncing():
|
400
|
+
error_response = {
|
401
|
+
"success": False,
|
402
|
+
"error": "Sync already in progress",
|
403
|
+
"message": "Wait for current sync to complete before starting a new one"
|
404
|
+
}
|
405
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
406
|
+
|
407
|
+
# Start sync without session tracking
|
408
|
+
import asyncio
|
409
|
+
sync_task = asyncio.create_task(
|
410
|
+
client.smart_sync_manager.sync_metadata(global_version_id, strategy)
|
411
|
+
)
|
412
|
+
|
413
|
+
response = {
|
414
|
+
"success": True,
|
415
|
+
"session_id": f"legacy_{global_version_id}_{strategy_str}",
|
416
|
+
"global_version_id": global_version_id,
|
417
|
+
"strategy": strategy_str,
|
418
|
+
"message": "Sync started (legacy mode)",
|
419
|
+
"instructions": "Use d365fo_get_sync_progress to monitor progress"
|
420
|
+
}
|
421
|
+
|
422
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
423
|
+
|
424
|
+
async def _fallback_get_progress(self, client, session_id: str) -> List[TextContent]:
|
425
|
+
"""Fallback progress check for original sync manager."""
|
426
|
+
if hasattr(client, 'smart_sync_manager'):
|
427
|
+
progress = client.smart_sync_manager.get_sync_progress()
|
428
|
+
is_syncing = client.smart_sync_manager.is_syncing()
|
429
|
+
|
430
|
+
if progress:
|
431
|
+
response = {
|
432
|
+
"success": True,
|
433
|
+
"session_id": session_id,
|
434
|
+
"legacy_mode": True,
|
435
|
+
"progress": {
|
436
|
+
"global_version_id": progress.global_version_id,
|
437
|
+
"strategy": progress.strategy,
|
438
|
+
"phase": progress.phase,
|
439
|
+
"total_steps": progress.total_steps,
|
440
|
+
"completed_steps": progress.completed_steps,
|
441
|
+
"current_operation": progress.current_operation,
|
442
|
+
"start_time": progress.start_time.isoformat() if progress.start_time else None,
|
443
|
+
"estimated_completion": progress.estimated_completion.isoformat() if progress.estimated_completion else None,
|
444
|
+
"error": progress.error,
|
445
|
+
"is_running": is_syncing
|
446
|
+
}
|
447
|
+
}
|
448
|
+
else:
|
449
|
+
response = {
|
450
|
+
"success": True,
|
451
|
+
"session_id": session_id,
|
452
|
+
"legacy_mode": True,
|
453
|
+
"message": "No sync currently running",
|
454
|
+
"is_running": False
|
455
|
+
}
|
456
|
+
|
457
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
458
|
+
|
459
|
+
error_response = {
|
460
|
+
"success": False,
|
461
|
+
"error": "No sync manager available",
|
462
|
+
"session_id": session_id
|
463
|
+
}
|
464
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
465
|
+
|
466
|
+
async def _fallback_list_sessions(self, client) -> List[TextContent]:
|
467
|
+
"""Fallback session list for original sync manager."""
|
468
|
+
if hasattr(client, 'smart_sync_manager'):
|
469
|
+
is_syncing = client.smart_sync_manager.is_syncing()
|
470
|
+
progress = client.smart_sync_manager.get_sync_progress()
|
471
|
+
|
472
|
+
active_sessions = []
|
473
|
+
if is_syncing and progress:
|
474
|
+
session = {
|
475
|
+
"session_id": f"legacy_{progress.global_version_id}_{progress.strategy}",
|
476
|
+
"global_version_id": progress.global_version_id,
|
477
|
+
"strategy": progress.strategy,
|
478
|
+
"status": "running",
|
479
|
+
"start_time": progress.start_time.isoformat() if progress.start_time else None,
|
480
|
+
"progress_percent": (progress.completed_steps / progress.total_steps * 100) if progress.total_steps > 0 else 0,
|
481
|
+
"current_phase": progress.phase,
|
482
|
+
"current_activity": progress.current_operation,
|
483
|
+
"initiated_by": "legacy",
|
484
|
+
"legacy_mode": True
|
485
|
+
}
|
486
|
+
active_sessions.append(session)
|
487
|
+
|
488
|
+
response = {
|
489
|
+
"success": True,
|
490
|
+
"active_sessions": active_sessions,
|
491
|
+
"total_count": len(active_sessions),
|
492
|
+
"running_count": len(active_sessions),
|
493
|
+
"legacy_mode": True,
|
494
|
+
"message": "Using legacy sync manager - limited session information available"
|
495
|
+
}
|
496
|
+
|
497
|
+
return [TextContent(type="text", text=json.dumps(response, indent=2))]
|
498
|
+
|
499
|
+
error_response = {
|
500
|
+
"success": False,
|
501
|
+
"error": "No sync manager available"
|
502
|
+
}
|
503
|
+
return [TextContent(type="text", text=json.dumps(error_response, indent=2))]
|
d365fo_client/metadata_api.py
CHANGED
@@ -360,6 +360,73 @@ class MetadataAPIOperations:
|
|
360
360
|
|
361
361
|
return entities
|
362
362
|
|
363
|
+
async def get_all_data_entities(self) -> List[DataEntityInfo]:
|
364
|
+
"""Get all data entities without pagination limits
|
365
|
+
|
366
|
+
Returns:
|
367
|
+
List of all data entities
|
368
|
+
"""
|
369
|
+
try:
|
370
|
+
# Use the search method without any filters to get all entities
|
371
|
+
# We could also use get_data_entities() with no top limit, but search handles pagination better
|
372
|
+
entities = []
|
373
|
+
|
374
|
+
# Get all entities by calling the raw endpoint with no top limit
|
375
|
+
options = QueryOptions()
|
376
|
+
options.select = [
|
377
|
+
"Name",
|
378
|
+
"PublicEntityName",
|
379
|
+
"PublicCollectionName",
|
380
|
+
"LabelId",
|
381
|
+
"DataServiceEnabled",
|
382
|
+
"DataManagementEnabled",
|
383
|
+
"EntityCategory",
|
384
|
+
"IsReadOnly"
|
385
|
+
]
|
386
|
+
|
387
|
+
data = await self.get_data_entities(options)
|
388
|
+
|
389
|
+
for item in data.get("value", []):
|
390
|
+
# Convert entity category string to enum
|
391
|
+
entity_category_str = item.get("EntityCategory")
|
392
|
+
entity_category = None
|
393
|
+
if entity_category_str:
|
394
|
+
try:
|
395
|
+
# Try to map the string to the enum
|
396
|
+
if entity_category_str == "Master":
|
397
|
+
entity_category = EntityCategory.MASTER
|
398
|
+
elif entity_category_str == "Transaction":
|
399
|
+
entity_category = EntityCategory.TRANSACTION
|
400
|
+
elif entity_category_str == "Configuration":
|
401
|
+
entity_category = EntityCategory.CONFIGURATION
|
402
|
+
elif entity_category_str == "Reference":
|
403
|
+
entity_category = EntityCategory.REFERENCE
|
404
|
+
elif entity_category_str == "Document":
|
405
|
+
entity_category = EntityCategory.DOCUMENT
|
406
|
+
elif entity_category_str == "Parameters":
|
407
|
+
entity_category = EntityCategory.PARAMETERS
|
408
|
+
# If no match, leave as None
|
409
|
+
except Exception:
|
410
|
+
entity_category = None
|
411
|
+
|
412
|
+
entity = DataEntityInfo(
|
413
|
+
name=item.get("Name", ""),
|
414
|
+
public_entity_name=item.get("PublicEntityName", ""),
|
415
|
+
public_collection_name=item.get("PublicCollectionName", ""),
|
416
|
+
label_id=item.get("LabelId"),
|
417
|
+
data_service_enabled=item.get("DataServiceEnabled", False),
|
418
|
+
data_management_enabled=item.get("DataManagementEnabled", False),
|
419
|
+
entity_category=entity_category,
|
420
|
+
is_read_only=item.get("IsReadOnly", False),
|
421
|
+
)
|
422
|
+
entities.append(entity)
|
423
|
+
|
424
|
+
return entities
|
425
|
+
|
426
|
+
except Exception as e:
|
427
|
+
logger.error(f"Error getting all data entities: {e}")
|
428
|
+
raise
|
429
|
+
|
363
430
|
async def get_data_entity_info(
|
364
431
|
self, entity_name: str, resolve_labels: bool = True, language: str = "en-US"
|
365
432
|
) -> Optional[DataEntityInfo]:
|
@@ -213,17 +213,19 @@ class MetadataCacheV2:
|
|
213
213
|
entities: List of data entity information
|
214
214
|
"""
|
215
215
|
async with aiosqlite.connect(self.db_path) as db:
|
216
|
-
|
217
|
-
await db.execute(
|
218
|
-
"DELETE FROM data_entities WHERE global_version_id = ?",
|
219
|
-
(global_version_id,),
|
220
|
-
)
|
216
|
+
|
221
217
|
|
222
218
|
# Insert new entities with label processing
|
223
219
|
for entity in entities:
|
224
220
|
# Process label fallback for this entity
|
225
221
|
processed_label_text = process_label_fallback(entity.label_id, entity.label_text)
|
226
|
-
|
222
|
+
|
223
|
+
# Clear existing entity for this version
|
224
|
+
await db.execute(
|
225
|
+
"DELETE FROM data_entities WHERE global_version_id = ? and name = ?",
|
226
|
+
(global_version_id, entity.name,),
|
227
|
+
)
|
228
|
+
|
227
229
|
await db.execute(
|
228
230
|
"""INSERT INTO data_entities
|
229
231
|
(global_version_id, name, public_entity_name, public_collection_name,
|
@@ -245,7 +247,7 @@ class MetadataCacheV2:
|
|
245
247
|
)
|
246
248
|
|
247
249
|
await db.commit()
|
248
|
-
logger.
|
250
|
+
logger.debug(
|
249
251
|
f"Stored {len(entities)} data entities for version {global_version_id}"
|
250
252
|
)
|
251
253
|
|
@@ -1357,7 +1359,7 @@ class MetadataCacheV2:
|
|
1357
1359
|
)
|
1358
1360
|
await db.commit()
|
1359
1361
|
|
1360
|
-
logger.
|
1362
|
+
logger.debug(
|
1361
1363
|
f"Batch cached {len(labels)} labels for version {global_version_id}"
|
1362
1364
|
)
|
1363
1365
|
|
@@ -1522,7 +1524,7 @@ class MetadataCacheV2:
|
|
1522
1524
|
stats["current_version"] = {
|
1523
1525
|
"global_version_id": version_info.id,
|
1524
1526
|
"version_hash": version_info.version_hash,
|
1525
|
-
"modules_count": len(version_info.
|
1527
|
+
"modules_count": len(version_info.modules),
|
1526
1528
|
"reference_count": version_info.reference_count,
|
1527
1529
|
}
|
1528
1530
|
|
@@ -325,13 +325,11 @@ class GlobalVersionManager:
|
|
325
325
|
first_seen_at=datetime.fromisoformat(row[3]),
|
326
326
|
last_used_at=datetime.fromisoformat(row[4]),
|
327
327
|
reference_count=row[5],
|
328
|
-
|
329
|
-
modules[:10] if modules else []
|
330
|
-
), # Use first 10 modules as sample
|
328
|
+
modules=modules
|
331
329
|
)
|
332
330
|
|
333
331
|
async def find_compatible_versions(
|
334
|
-
self, modules: List[ModuleVersionInfo], exact_match: bool =
|
332
|
+
self, modules: List[ModuleVersionInfo], exact_match: bool = True
|
335
333
|
) -> List[GlobalVersionInfo]:
|
336
334
|
"""Find compatible global versions
|
337
335
|
|
@@ -656,7 +656,7 @@ class SmartSyncManagerV2:
|
|
656
656
|
|
657
657
|
# Check for compatible versions (sharing opportunity)
|
658
658
|
compatible_versions = await self.version_manager.find_compatible_versions(
|
659
|
-
version_info.
|
659
|
+
version_info.modules, exact_match=True
|
660
660
|
)
|
661
661
|
|
662
662
|
for version in compatible_versions:
|