surfdataverse 2.1.0__tar.gz → 3.0.0__tar.gz
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.
- {surfdataverse-2.1.0 → surfdataverse-3.0.0}/PKG-INFO +6 -3
- {surfdataverse-2.1.0 → surfdataverse-3.0.0}/README.md +3 -2
- {surfdataverse-2.1.0 → surfdataverse-3.0.0}/pyproject.toml +3 -1
- {surfdataverse-2.1.0 → surfdataverse-3.0.0}/src/surfdataverse/__init__.py +8 -1
- surfdataverse-3.0.0/src/surfdataverse/container.py +49 -0
- {surfdataverse-2.1.0 → surfdataverse-3.0.0}/src/surfdataverse/core.py +433 -370
- {surfdataverse-2.1.0 → surfdataverse-3.0.0}/src/surfdataverse/exceptions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: surfdataverse
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: A Python package for ionysis Microsoft Dataverse integration
|
|
5
5
|
Keywords: dataverse,microsoft,crm,api
|
|
6
6
|
Author: ionysis
|
|
@@ -14,9 +14,11 @@ Classifier: Programming Language :: Python :: 3.8
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.9
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Requires-Dist: dependency-injector>=4.42.0
|
|
17
18
|
Requires-Dist: msal>=1.33.0
|
|
18
19
|
Requires-Dist: numpy>=2.3.3
|
|
19
20
|
Requires-Dist: pandas>=2.3.2
|
|
21
|
+
Requires-Dist: polars>=1.34.0
|
|
20
22
|
Requires-Dist: requests>=2.32.5
|
|
21
23
|
Requires-Python: >=3.11
|
|
22
24
|
Project-URL: Bug Tracker, https://github.com/FriedemannHeinz/SurfDataverse/issues
|
|
@@ -222,11 +224,12 @@ For custom scenarios, you can create properties manually:
|
|
|
222
224
|
```python
|
|
223
225
|
from surfdataverse import DataverseTable
|
|
224
226
|
|
|
227
|
+
|
|
225
228
|
# Extend the base class
|
|
226
229
|
class CustomTable(DataverseTable):
|
|
227
230
|
def __init__(self, logical_name, prefix="prefix_"):
|
|
228
|
-
super().__init__(logical_name,
|
|
229
|
-
|
|
231
|
+
super().__init__(logical_name, prefix=prefix)
|
|
232
|
+
|
|
230
233
|
# Add custom business logic
|
|
231
234
|
def validate_data(self):
|
|
232
235
|
if not self.name:
|
|
@@ -195,11 +195,12 @@ For custom scenarios, you can create properties manually:
|
|
|
195
195
|
```python
|
|
196
196
|
from surfdataverse import DataverseTable
|
|
197
197
|
|
|
198
|
+
|
|
198
199
|
# Extend the base class
|
|
199
200
|
class CustomTable(DataverseTable):
|
|
200
201
|
def __init__(self, logical_name, prefix="prefix_"):
|
|
201
|
-
super().__init__(logical_name,
|
|
202
|
-
|
|
202
|
+
super().__init__(logical_name, prefix=prefix)
|
|
203
|
+
|
|
203
204
|
# Add custom business logic
|
|
204
205
|
def validate_data(self):
|
|
205
206
|
if not self.name:
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "surfdataverse"
|
|
7
|
-
version = "
|
|
7
|
+
version = "3.0.0"
|
|
8
8
|
description = "A Python package for ionysis Microsoft Dataverse integration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -24,9 +24,11 @@ classifiers = [
|
|
|
24
24
|
keywords = ["dataverse", "microsoft", "crm", "api"]
|
|
25
25
|
requires-python = ">=3.11"
|
|
26
26
|
dependencies = [
|
|
27
|
+
"dependency-injector>=4.42.0",
|
|
27
28
|
"msal>=1.33.0",
|
|
28
29
|
"numpy>=2.3.3",
|
|
29
30
|
"pandas>=2.3.2",
|
|
31
|
+
"polars>=1.34.0",
|
|
30
32
|
"requests>=2.32.5",
|
|
31
33
|
]
|
|
32
34
|
|
|
@@ -10,7 +10,8 @@ Main Components:
|
|
|
10
10
|
- Entity classes: Article, Recipe, Ingredient, etc.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
from .
|
|
13
|
+
from .container import connect_entity, connect_table, container, get_client, reset_container
|
|
14
|
+
from .core import DataverseClient, DataverseEntity, DataverseTable, is_valid_guid
|
|
14
15
|
from .exceptions import (
|
|
15
16
|
AuthenticationError,
|
|
16
17
|
ConfigurationError,
|
|
@@ -26,7 +27,13 @@ __author__ = "Friedemann Heinz"
|
|
|
26
27
|
__all__ = [
|
|
27
28
|
"DataverseClient",
|
|
28
29
|
"DataverseTable",
|
|
30
|
+
"DataverseEntity",
|
|
29
31
|
"is_valid_guid",
|
|
32
|
+
"get_client",
|
|
33
|
+
"connect_table",
|
|
34
|
+
"connect_entity",
|
|
35
|
+
"reset_container",
|
|
36
|
+
"container",
|
|
30
37
|
"SurfDataverseError",
|
|
31
38
|
"AuthenticationError",
|
|
32
39
|
"ConnectionError",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency injection container for SurfDataverse.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dependency_injector import containers, providers
|
|
6
|
+
|
|
7
|
+
from .core import DataverseClient, DataverseEntity, DataverseTable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Container(containers.DeclarativeContainer):
|
|
11
|
+
"""Main dependency injection container for SurfDataverse"""
|
|
12
|
+
|
|
13
|
+
# Configuration
|
|
14
|
+
config = providers.Configuration()
|
|
15
|
+
|
|
16
|
+
# Core client as singleton
|
|
17
|
+
client = providers.Singleton(DataverseClient, config_path=config.config_path)
|
|
18
|
+
|
|
19
|
+
# Table factory - creates DataverseTable instances with injected client
|
|
20
|
+
table_factory = providers.Factory(DataverseTable, client=client)
|
|
21
|
+
|
|
22
|
+
# Entity factory - creates DataverseEntity instances with injected client
|
|
23
|
+
entity_factory = providers.Factory(DataverseEntity, client=client)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Global container instance
|
|
27
|
+
container = Container()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_client(config_path=None) -> DataverseClient:
|
|
31
|
+
"""Get the singleton DataverseClient instance"""
|
|
32
|
+
if config_path:
|
|
33
|
+
container.config.config_path.from_value(config_path)
|
|
34
|
+
return container.client()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def connect_table(logical_name: str = "", prefix: str = "") -> DataverseTable:
|
|
38
|
+
"""Connect to a Dataverse table"""
|
|
39
|
+
return container.table_factory(logical_name, prefix)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def connect_entity(table_logical_name: str, table_prefix: str = "") -> DataverseEntity:
|
|
43
|
+
"""Connect to a new Dataverse entity instance"""
|
|
44
|
+
return container.entity_factory(table_logical_name, table_prefix)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def reset_container():
|
|
48
|
+
"""Reset the container - useful for testing"""
|
|
49
|
+
container.reset_singletons()
|
|
@@ -4,13 +4,13 @@ import logging
|
|
|
4
4
|
import sys
|
|
5
5
|
import uuid
|
|
6
6
|
from decimal import Decimal
|
|
7
|
-
from enum import Enum
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
from typing import Optional
|
|
10
9
|
|
|
11
10
|
import msal
|
|
12
11
|
import pandas as pd
|
|
13
12
|
import requests
|
|
13
|
+
from pandas.io.sql import table_exists
|
|
14
14
|
|
|
15
15
|
from .exceptions import (
|
|
16
16
|
AuthenticationError,
|
|
@@ -45,22 +45,20 @@ def user_config_dir(app_name: str, company: str) -> Path:
|
|
|
45
45
|
return app_config_dir
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
"""Metaclass for creating a Singleton."""
|
|
48
|
+
# Singleton pattern now handled by dependency injection container
|
|
50
49
|
|
|
51
|
-
_instances: dict = {}
|
|
52
|
-
|
|
53
|
-
def __call__(cls, *args, **kwargs):
|
|
54
|
-
if cls not in cls._instances:
|
|
55
|
-
cls._instances[cls] = super().__call__(*args, **kwargs)
|
|
56
|
-
return cls._instances[cls]
|
|
57
50
|
|
|
51
|
+
def is_valid_guid(value: str) -> bool:
|
|
52
|
+
"""Returns True if the value is a valid GUID, otherwise False."""
|
|
53
|
+
try:
|
|
54
|
+
uuid_obj = uuid.UUID(value) # Ensure it's a valid UUID
|
|
55
|
+
return str(uuid_obj) == value.lower() # Ensure correct format
|
|
56
|
+
except ValueError:
|
|
57
|
+
return False
|
|
58
58
|
|
|
59
|
-
class DataverseClient(metaclass=SingletonMeta):
|
|
60
|
-
def __init__(self, config_path=None, *args):
|
|
61
|
-
if hasattr(self, "_initialized"):
|
|
62
|
-
return # Prevent reinitialization
|
|
63
59
|
|
|
60
|
+
class DataverseClient:
|
|
61
|
+
def __init__(self, config_path=None):
|
|
64
62
|
self.connected = False
|
|
65
63
|
self.session = requests.Session()
|
|
66
64
|
self.environment_uri: str = ""
|
|
@@ -69,18 +67,6 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
69
67
|
self.account = None # Store the logged-in account
|
|
70
68
|
self.config_path = config_path
|
|
71
69
|
|
|
72
|
-
if "xml" in args:
|
|
73
|
-
self.filetype = "xml"
|
|
74
|
-
else:
|
|
75
|
-
self.filetype = "json"
|
|
76
|
-
|
|
77
|
-
# Stored values for this session
|
|
78
|
-
self._table_metadata = {}
|
|
79
|
-
self._global_choices = {}
|
|
80
|
-
self._guid_mapping = {}
|
|
81
|
-
self._table_definitions = {}
|
|
82
|
-
self._relationships = {}
|
|
83
|
-
|
|
84
70
|
# === CONNECTION, AUTHENTICATION, TEST
|
|
85
71
|
def get_authenticated_session(self, config_json: Optional[str | Path] = None):
|
|
86
72
|
if config_json is None and self.config_path is None:
|
|
@@ -131,7 +117,7 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
131
117
|
"OData-Version": "4.0",
|
|
132
118
|
"If-None-Match": "null",
|
|
133
119
|
"Prefer": 'odata.include-annotations="*"',
|
|
134
|
-
"Accept":
|
|
120
|
+
"Accept": "application/json",
|
|
135
121
|
}
|
|
136
122
|
)
|
|
137
123
|
with open(cache_file, "w") as f:
|
|
@@ -192,14 +178,37 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
192
178
|
except requests.RequestException as e:
|
|
193
179
|
raise ConnectionError(f"Network error during connection test: {str(e)}")
|
|
194
180
|
|
|
181
|
+
|
|
182
|
+
class DataverseBase:
|
|
183
|
+
"""Base class for Dataverse operations with shared functionality"""
|
|
184
|
+
|
|
185
|
+
def __init__(self, table_logical_name: str = "", prefix="", client=None):
|
|
186
|
+
self.client = client or DataverseClient()
|
|
187
|
+
|
|
188
|
+
self.table_logical_name = table_logical_name
|
|
189
|
+
self.table_logical_name_prefix = prefix
|
|
190
|
+
self.name = (
|
|
191
|
+
table_logical_name.replace(self.table_logical_name_prefix, "").title().replace("_", " ")
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Stored values for this session
|
|
195
|
+
self._guid_mapping = {}
|
|
196
|
+
self._all_relationships = {} # Populated by first call
|
|
197
|
+
self._global_choices = {} # Populated by first call
|
|
198
|
+
|
|
199
|
+
self._table_metadata = {}
|
|
200
|
+
self._table_definitions = {}
|
|
201
|
+
self._table_relationships = {}
|
|
202
|
+
self._filtered_choices = {}
|
|
203
|
+
|
|
195
204
|
# === RELATIONSHIPS
|
|
196
205
|
@property
|
|
197
|
-
def
|
|
198
|
-
if not self.
|
|
199
|
-
request_uri = f"{self.environment_uri}api/data/v9.2/RelationshipDefinitions"
|
|
200
|
-
response = self.session.get(request_uri)
|
|
206
|
+
def all_relationships(self):
|
|
207
|
+
if not self._all_relationships:
|
|
208
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/RelationshipDefinitions"
|
|
209
|
+
response = self.client.session.get(request_uri)
|
|
201
210
|
if response.status_code == 200:
|
|
202
|
-
self.
|
|
211
|
+
self._all_relationships = response.json()
|
|
203
212
|
else:
|
|
204
213
|
logger.error(f"Error fetching relationships: {response.text}")
|
|
205
214
|
raise DataverseAPIError(
|
|
@@ -207,12 +216,12 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
207
216
|
response.status_code,
|
|
208
217
|
response.text,
|
|
209
218
|
)
|
|
210
|
-
return self.
|
|
219
|
+
return self._all_relationships
|
|
211
220
|
|
|
212
221
|
# === FLOWS
|
|
213
222
|
def get_flows(self):
|
|
214
|
-
request_uri = f"{self.environment_uri}api/data/v9.2/workflows"
|
|
215
|
-
response = self.session.get(request_uri)
|
|
223
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/workflows"
|
|
224
|
+
response = self.client.session.get(request_uri)
|
|
216
225
|
if response.status_code == 401:
|
|
217
226
|
raise AuthenticationError("Unauthorized. Check your credentials and permissions.")
|
|
218
227
|
elif response.status_code != 200:
|
|
@@ -226,8 +235,8 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
226
235
|
def global_choices(self):
|
|
227
236
|
"""Fetch all global choices (option sets) from Dataverse"""
|
|
228
237
|
if not self._global_choices:
|
|
229
|
-
request_uri = f"{self.environment_uri}api/data/v9.2/GlobalOptionSetDefinitions"
|
|
230
|
-
response = self.session.get(request_uri)
|
|
238
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/GlobalOptionSetDefinitions"
|
|
239
|
+
response = self.client.session.get(request_uri)
|
|
231
240
|
|
|
232
241
|
if response.status_code == 200:
|
|
233
242
|
choices_data = response.json().get("value", [])
|
|
@@ -253,11 +262,22 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
253
262
|
|
|
254
263
|
return self._global_choices
|
|
255
264
|
|
|
265
|
+
@property
|
|
266
|
+
def filtered_choices(self):
|
|
267
|
+
"""Get choices filtered by table prefix"""
|
|
268
|
+
if not self._filtered_choices:
|
|
269
|
+
self._filtered_choices = {
|
|
270
|
+
name: options
|
|
271
|
+
for name, options in self.global_choices.items()
|
|
272
|
+
if self.table_logical_name_prefix in name
|
|
273
|
+
}
|
|
274
|
+
return self._filtered_choices
|
|
275
|
+
|
|
256
276
|
# === TABLES
|
|
257
277
|
def get_table_definitions(self):
|
|
258
278
|
if not self._table_definitions:
|
|
259
|
-
request_uri = f"{self.environment_uri}api/data/v9.2/EntityDefinitions"
|
|
260
|
-
response = self.session.get(request_uri)
|
|
279
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/EntityDefinitions"
|
|
280
|
+
response = self.client.session.get(request_uri)
|
|
261
281
|
if response.status_code == 200:
|
|
262
282
|
self._table_definitions = response.json()
|
|
263
283
|
else:
|
|
@@ -269,10 +289,24 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
269
289
|
)
|
|
270
290
|
return self._table_definitions
|
|
271
291
|
|
|
292
|
+
def get_table_relationships(self) -> dict:
|
|
293
|
+
"""Fetches all possible lookups (relationships) for this entity."""
|
|
294
|
+
result = self.all_relationships
|
|
295
|
+
|
|
296
|
+
if not self._table_relationships:
|
|
297
|
+
_relationships = {}
|
|
298
|
+
for rel in result.get("value", []):
|
|
299
|
+
if rel.get("ReferencingEntity") == self.table_logical_name:
|
|
300
|
+
# Store lookup column name -> related entity table
|
|
301
|
+
if "ReferencingAttribute" in rel and "ReferencedEntity" in rel:
|
|
302
|
+
_relationships[rel["ReferencingAttribute"]] = rel["ReferencedEntity"]
|
|
303
|
+
self._table_relationships = _relationships
|
|
304
|
+
return self._table_relationships
|
|
305
|
+
|
|
272
306
|
def get_table_metadata_raw(self, table_logical_name):
|
|
273
307
|
"""Fetch column metadata for a specific Dataverse table"""
|
|
274
|
-
request_uri = f"{self.environment_uri}api/data/v9.2/EntityDefinitions(LogicalName='{table_logical_name}')?$expand=Attributes"
|
|
275
|
-
response = self.session.get(request_uri)
|
|
308
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/EntityDefinitions(LogicalName='{table_logical_name}')?$expand=Attributes"
|
|
309
|
+
response = self.client.session.get(request_uri)
|
|
276
310
|
|
|
277
311
|
if response.status_code == 200:
|
|
278
312
|
return response.json() # Full metadata response
|
|
@@ -281,8 +315,10 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
281
315
|
logger.error(error_msg)
|
|
282
316
|
raise DataverseAPIError(error_msg, response.status_code, response.text)
|
|
283
317
|
|
|
284
|
-
def get_table_metadata(self, table_logical_name):
|
|
318
|
+
def get_table_metadata(self, table_logical_name=None):
|
|
285
319
|
"""Finds fields that users can set in create/update operations"""
|
|
320
|
+
if table_logical_name is None:
|
|
321
|
+
table_logical_name = self.table_logical_name
|
|
286
322
|
if table_logical_name not in self._table_metadata:
|
|
287
323
|
raw_metadata = self.get_table_metadata_raw(table_logical_name)
|
|
288
324
|
if not raw_metadata:
|
|
@@ -310,13 +346,120 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
310
346
|
|
|
311
347
|
return self._table_metadata[table_logical_name]
|
|
312
348
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
349
|
+
# === RECORDS
|
|
350
|
+
def get_record(self, entity_set_name, record_id):
|
|
351
|
+
"""Fetches a newly created record by ID to retrieve auto-generated fields."""
|
|
352
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({record_id})"
|
|
353
|
+
response = self.client.session.get(request_uri)
|
|
354
|
+
|
|
355
|
+
if response.status_code == 200:
|
|
356
|
+
return response.json()
|
|
357
|
+
else:
|
|
358
|
+
error_msg = f"Error fetching record: {response.text}"
|
|
359
|
+
logger.error(error_msg)
|
|
360
|
+
raise DataverseAPIError(error_msg, response.status_code, response.text)
|
|
361
|
+
|
|
362
|
+
# === HELPER METHODS
|
|
363
|
+
def name_to_guid(self, table_name, name=None, name_column=None) -> uuid.UUID | None:
|
|
364
|
+
"""
|
|
365
|
+
Fetches a mapping of unique names to GUIDs in a Dataverse table
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
table_name: logical_name of table
|
|
369
|
+
name: name to map to guid
|
|
370
|
+
name_column: The column name to use for lookup (if not provided, will be inferred)
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
|
|
374
|
+
"""
|
|
375
|
+
# If name_column not provided, try to infer it
|
|
376
|
+
if not name_column:
|
|
377
|
+
# Extract prefix from table name
|
|
378
|
+
if "_" in table_name:
|
|
379
|
+
prefix = table_name.split("_")[0] + "_"
|
|
380
|
+
table_suffix = table_name.replace(prefix, "")
|
|
381
|
+
|
|
382
|
+
if table_suffix in ["article", "batch"]:
|
|
383
|
+
name_column = table_name + "nr"
|
|
384
|
+
elif table_suffix == "r2rsession":
|
|
385
|
+
name_column = f"{prefix}sessionid"
|
|
386
|
+
else:
|
|
387
|
+
name_column = f"{prefix}name" # Default fallback
|
|
388
|
+
else:
|
|
389
|
+
# For tables without prefix, assume standard naming
|
|
390
|
+
name_column = table_name + "name"
|
|
391
|
+
|
|
392
|
+
guid_column = table_name + "id"
|
|
393
|
+
|
|
394
|
+
def update_mapping():
|
|
395
|
+
entity_set_name = self.get_table_entity_set_name(logical_name=table_name)
|
|
396
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}?$select={name_column},{guid_column}"
|
|
397
|
+
|
|
398
|
+
response = self.client.session.get(request_uri)
|
|
399
|
+
|
|
400
|
+
if response.status_code == 200:
|
|
401
|
+
records = response.json().get("value", [])
|
|
402
|
+
mapping = {
|
|
403
|
+
record[name_column]: record[guid_column]
|
|
404
|
+
for record in records
|
|
405
|
+
if name_column in record and guid_column in record
|
|
406
|
+
}
|
|
407
|
+
self._guid_mapping[table_name] = mapping
|
|
408
|
+
else:
|
|
409
|
+
error_msg = (
|
|
410
|
+
f"Error fetching data from {table_name} for guid mapping: {response.text}"
|
|
411
|
+
)
|
|
412
|
+
logger.error(error_msg)
|
|
413
|
+
raise DataverseAPIError(error_msg, response.status_code, response.text)
|
|
414
|
+
|
|
415
|
+
if table_name not in self._guid_mapping:
|
|
416
|
+
update_mapping()
|
|
417
|
+
|
|
418
|
+
if name:
|
|
419
|
+
guid = self._guid_mapping[table_name].get(name, None)
|
|
420
|
+
if not guid:
|
|
421
|
+
raise EntityError(f"{name} not found in {table_name}")
|
|
422
|
+
else:
|
|
423
|
+
return guid
|
|
424
|
+
else:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
def get_table_of_guid(self, guid):
|
|
428
|
+
return next(
|
|
429
|
+
(
|
|
430
|
+
key
|
|
431
|
+
for key, value in self._guid_mapping.items()
|
|
432
|
+
if guid in [guid for guid in value.values()]
|
|
433
|
+
),
|
|
434
|
+
None,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def get_table_entity_set_name(self, schema_name=None, logical_name=None):
|
|
438
|
+
tables = self.get_table_definitions()
|
|
439
|
+
|
|
440
|
+
if schema_name:
|
|
441
|
+
key = "SchemaName"
|
|
442
|
+
name = schema_name
|
|
443
|
+
elif logical_name:
|
|
444
|
+
key = "LogicalName"
|
|
445
|
+
name = logical_name
|
|
446
|
+
else:
|
|
447
|
+
raise ValueError("Please input schema or logical name")
|
|
448
|
+
|
|
449
|
+
for entity in tables["value"]:
|
|
450
|
+
if name == entity[key]:
|
|
451
|
+
return entity["EntitySetName"]
|
|
452
|
+
raise EntityError(f"{key} not found for {name}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class DataverseTable(DataverseBase):
|
|
456
|
+
"""Table-level operations for Dataverse - inherits shared functionality from DataverseBase"""
|
|
457
|
+
|
|
458
|
+
# === TABLES
|
|
459
|
+
def get_table_data(self, table_logical_name = None, polars=False):
|
|
460
|
+
"""Fetches actual data from this table
|
|
317
461
|
Args:
|
|
318
|
-
|
|
319
|
-
entity_set_name: The entity set name (alternative to logical_name)
|
|
462
|
+
table_logical_name: logical name of the table (optional)
|
|
320
463
|
polars: Return Polars DataFrame instead of pandas
|
|
321
464
|
"""
|
|
322
465
|
|
|
@@ -332,16 +475,10 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
332
475
|
df = pl.DataFrame(data_list)
|
|
333
476
|
return df
|
|
334
477
|
|
|
335
|
-
|
|
336
|
-
pass
|
|
337
|
-
elif logical_name and not entity_set_name:
|
|
338
|
-
entity_set_name = self.get_table_entity_set_name(logical_name=logical_name)
|
|
339
|
-
else:
|
|
340
|
-
logger.info("Please provide logical or entity set name")
|
|
341
|
-
return None
|
|
478
|
+
entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name or table_logical_name)
|
|
342
479
|
|
|
343
|
-
request_uri = f"{self.environment_uri}api/data/v9.2/{entity_set_name}"
|
|
344
|
-
response = self.session.get(request_uri)
|
|
480
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}"
|
|
481
|
+
response = self.client.session.get(request_uri)
|
|
345
482
|
|
|
346
483
|
if response.status_code == 200:
|
|
347
484
|
return entity_to_polars(response.json()) if polars else entity_to_df(response.json())
|
|
@@ -350,7 +487,78 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
350
487
|
logger.error(error_msg)
|
|
351
488
|
raise DataverseAPIError(error_msg, response.status_code, response.text)
|
|
352
489
|
|
|
353
|
-
def
|
|
490
|
+
def get_column_data(self, columns, entity_set_name=None, polars=False):
|
|
491
|
+
"""Fetches specific column data from this table
|
|
492
|
+
Args:
|
|
493
|
+
columns: Column name (string) or list of column names to retrieve
|
|
494
|
+
entity_set_name: The entity set name (optional, will be auto-determined)
|
|
495
|
+
polars: Return Polars DataFrame/Series instead of pandas
|
|
496
|
+
Returns:
|
|
497
|
+
pandas.Series (single column) or pandas.DataFrame (multiple columns)
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
def entity_to_df(entity_data, cols):
|
|
501
|
+
data_list = entity_data["value"]
|
|
502
|
+
df = pd.DataFrame(data_list)
|
|
503
|
+
# Return only requested columns that exist in the data
|
|
504
|
+
available_cols = [col for col in cols if col in df.columns]
|
|
505
|
+
if not available_cols:
|
|
506
|
+
logger.warning(f"None of the requested columns {cols} found in data")
|
|
507
|
+
return pd.DataFrame() if len(cols) > 1 else pd.Series(dtype=object)
|
|
508
|
+
result = df[available_cols]
|
|
509
|
+
return (
|
|
510
|
+
result.iloc[:, 0]
|
|
511
|
+
if len(available_cols) == 1 and isinstance(columns, str)
|
|
512
|
+
else result
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def entity_to_polars(entity_data, cols):
|
|
516
|
+
import polars as pl
|
|
517
|
+
|
|
518
|
+
data_list = entity_data["value"]
|
|
519
|
+
df = pl.DataFrame(data_list)
|
|
520
|
+
# Return only requested columns that exist in the data
|
|
521
|
+
available_cols = [col for col in cols if col in df.columns]
|
|
522
|
+
if not available_cols:
|
|
523
|
+
logger.warning(f"None of the requested columns {cols} found in data")
|
|
524
|
+
return pl.DataFrame() if len(cols) > 1 else pl.Series(dtype=pl.Null)
|
|
525
|
+
result = df.select(available_cols)
|
|
526
|
+
return (
|
|
527
|
+
result.to_series()
|
|
528
|
+
if len(available_cols) == 1 and isinstance(columns, str)
|
|
529
|
+
else result
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# Handle single column vs list of columns
|
|
533
|
+
if isinstance(columns, str):
|
|
534
|
+
column_list = [columns]
|
|
535
|
+
elif isinstance(columns, list):
|
|
536
|
+
column_list = columns
|
|
537
|
+
else:
|
|
538
|
+
raise ValueError("columns must be a string or list of strings")
|
|
539
|
+
|
|
540
|
+
if not entity_set_name:
|
|
541
|
+
entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
|
|
542
|
+
|
|
543
|
+
# Build OData $select query for specific columns
|
|
544
|
+
select_clause = ",".join(column_list)
|
|
545
|
+
request_uri = (
|
|
546
|
+
f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}?$select={select_clause}"
|
|
547
|
+
)
|
|
548
|
+
response = self.client.session.get(request_uri)
|
|
549
|
+
|
|
550
|
+
if response.status_code == 200:
|
|
551
|
+
return (
|
|
552
|
+
entity_to_polars(response.json(), column_list)
|
|
553
|
+
if polars
|
|
554
|
+
else entity_to_df(response.json(), column_list)
|
|
555
|
+
)
|
|
556
|
+
else:
|
|
557
|
+
error_msg = f"Failed to retrieve column data for {entity_set_name}: {response.text}"
|
|
558
|
+
logger.error(error_msg)
|
|
559
|
+
raise DataverseAPIError(error_msg, response.status_code, response.text)
|
|
560
|
+
|
|
561
|
+
def download_tables_as_df(self, schema_filter=None) -> tuple[dict, dict, dict]:
|
|
354
562
|
"""Download all tables as DataFrames, optionally filtered by schema name
|
|
355
563
|
|
|
356
564
|
Args:
|
|
@@ -368,19 +576,18 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
368
576
|
continue
|
|
369
577
|
|
|
370
578
|
if table["DisplayName"]["UserLocalizedLabel"]:
|
|
371
|
-
|
|
372
|
-
table_name = logical_name
|
|
579
|
+
table_logical_name = table["LogicalName"]
|
|
373
580
|
|
|
374
581
|
# Get selected definitions
|
|
375
|
-
_table_definitions[
|
|
582
|
+
_table_definitions[table_logical_name] = table
|
|
376
583
|
|
|
377
584
|
# Get data
|
|
378
|
-
_table_data[
|
|
379
|
-
if _table_data[
|
|
380
|
-
return
|
|
585
|
+
_table_data[table_logical_name] = self.get_table_data(table_logical_name=table_logical_name)
|
|
586
|
+
if _table_data[table_logical_name] is None:
|
|
587
|
+
return {}, {}, {}
|
|
381
588
|
|
|
382
589
|
# Get metadata
|
|
383
|
-
_table_metadata[
|
|
590
|
+
_table_metadata[table_logical_name] = self.get_table_metadata(table_logical_name) or {}
|
|
384
591
|
|
|
385
592
|
return _table_definitions, _table_data, _table_metadata
|
|
386
593
|
|
|
@@ -450,270 +657,93 @@ class DataverseClient(metaclass=SingletonMeta):
|
|
|
450
657
|
|
|
451
658
|
return df_transformed
|
|
452
659
|
|
|
453
|
-
# === RECORDS
|
|
454
|
-
def get_record(self, entity_set_name, record_id):
|
|
455
|
-
"""Fetches a newly created record by ID to retrieve auto-generated fields."""
|
|
456
|
-
request_uri = f"{self.environment_uri}api/data/v9.2/{entity_set_name}({record_id})"
|
|
457
|
-
response = self.session.get(request_uri)
|
|
458
|
-
|
|
459
|
-
if response.status_code == 200:
|
|
460
|
-
return response.json() # Return the full record with auto-filled values
|
|
461
|
-
else:
|
|
462
|
-
error_msg = f"Error fetching record: {response.text}"
|
|
463
|
-
logger.error(error_msg)
|
|
464
|
-
raise DataverseAPIError(error_msg, response.status_code, response.text)
|
|
465
|
-
|
|
466
|
-
# === HELPER METHODS
|
|
467
|
-
def get_guid_by_casual_name(self, table_name, data, name_column=None) -> uuid.UUID | None:
|
|
468
|
-
"""
|
|
469
|
-
Map display names to GUIDs if row exists
|
|
470
|
-
|
|
471
|
-
Args:
|
|
472
|
-
table_name: logical_name of table
|
|
473
|
-
data: data which holds the name to map to guid
|
|
474
|
-
name_column: The column name to use for lookup (if not provided, will be inferred)
|
|
475
|
-
|
|
476
|
-
Returns:
|
|
477
|
-
|
|
478
|
-
"""
|
|
479
|
-
# If name_column not provided, try to infer it
|
|
480
|
-
if not name_column:
|
|
481
|
-
# Extract prefix from table name
|
|
482
|
-
if "_" in table_name:
|
|
483
|
-
prefix = table_name.split("_")[0] + "_"
|
|
484
|
-
table_suffix = table_name.replace(prefix, "")
|
|
485
|
-
|
|
486
|
-
if table_suffix == "recipe":
|
|
487
|
-
name_column = f"{prefix}namecasual"
|
|
488
|
-
elif table_suffix == "article":
|
|
489
|
-
name_column = f"{prefix}name"
|
|
490
|
-
elif table_suffix == "r2rsession":
|
|
491
|
-
name_column = f"{prefix}sessionid"
|
|
492
|
-
else:
|
|
493
|
-
name_column = f"{prefix}name" # Default fallback
|
|
494
|
-
else:
|
|
495
|
-
raise EntityError(
|
|
496
|
-
f"Cannot infer name column for {table_name}. Please provide name_column parameter."
|
|
497
|
-
)
|
|
498
|
-
|
|
499
|
-
guid_column = table_name + "id"
|
|
500
|
-
|
|
501
|
-
name = data[name_column]
|
|
502
|
-
|
|
503
|
-
entity_set_name = self.get_table_entity_set_name(logical_name=table_name)
|
|
504
|
-
request_uri = f"{self.environment_uri}api/data/v9.2/{entity_set_name}?$select={name_column},{guid_column}"
|
|
505
|
-
|
|
506
|
-
response = self.session.get(request_uri)
|
|
507
|
-
|
|
508
|
-
if response.status_code == 200:
|
|
509
|
-
records = response.json().get("value", [])
|
|
510
|
-
mapping = {
|
|
511
|
-
record[name_column]: record[guid_column]
|
|
512
|
-
for record in records
|
|
513
|
-
if name_column in record and guid_column in record
|
|
514
|
-
}
|
|
515
|
-
return mapping.get(name, None)
|
|
516
|
-
else:
|
|
517
|
-
error_msg = (
|
|
518
|
-
f"Error fetching data from {table_name} for row existence check: {response.text}"
|
|
519
|
-
)
|
|
520
|
-
logger.error(error_msg)
|
|
521
|
-
raise DataverseAPIError(error_msg, response.status_code, response.text)
|
|
522
|
-
|
|
523
|
-
def name_to_guid(self, table_name, name=None, name_column=None) -> uuid.UUID | None:
|
|
524
|
-
"""
|
|
525
|
-
Fetches a mapping of unique names to GUIDs in a Dataverse table
|
|
526
|
-
|
|
527
|
-
Args:
|
|
528
|
-
table_name: logical_name of table
|
|
529
|
-
name: name to map to guid
|
|
530
|
-
name_column: The column name to use for lookup (if not provided, will be inferred)
|
|
531
|
-
|
|
532
|
-
Returns:
|
|
533
|
-
|
|
534
|
-
"""
|
|
535
|
-
# If name_column not provided, try to infer it
|
|
536
|
-
if not name_column:
|
|
537
|
-
# Extract prefix from table name
|
|
538
|
-
if "_" in table_name:
|
|
539
|
-
prefix = table_name.split("_")[0] + "_"
|
|
540
|
-
table_suffix = table_name.replace(prefix, "")
|
|
541
|
-
|
|
542
|
-
if table_suffix in ["article", "batch"]:
|
|
543
|
-
name_column = table_name + "nr"
|
|
544
|
-
elif table_suffix == "r2rsession":
|
|
545
|
-
name_column = f"{prefix}sessionid"
|
|
546
|
-
else:
|
|
547
|
-
name_column = f"{prefix}name" # Default fallback
|
|
548
|
-
else:
|
|
549
|
-
# For tables without prefix, assume standard naming
|
|
550
|
-
name_column = table_name + "name"
|
|
551
|
-
|
|
552
|
-
guid_column = table_name + "id"
|
|
553
|
-
|
|
554
|
-
def update_mapping():
|
|
555
|
-
entity_set_name = self.get_table_entity_set_name(logical_name=table_name)
|
|
556
|
-
request_uri = f"{self.environment_uri}api/data/v9.2/{entity_set_name}?$select={name_column},{guid_column}"
|
|
557
|
-
|
|
558
|
-
response = self.session.get(request_uri)
|
|
559
|
-
|
|
560
|
-
if response.status_code == 200:
|
|
561
|
-
records = response.json().get("value", [])
|
|
562
|
-
mapping = {
|
|
563
|
-
record[name_column]: record[guid_column]
|
|
564
|
-
for record in records
|
|
565
|
-
if name_column in record and guid_column in record
|
|
566
|
-
}
|
|
567
|
-
self._guid_mapping[table_name] = mapping
|
|
568
|
-
else:
|
|
569
|
-
error_msg = (
|
|
570
|
-
f"Error fetching data from {table_name} for guid mapping: {response.text}"
|
|
571
|
-
)
|
|
572
|
-
logger.error(error_msg)
|
|
573
|
-
raise DataverseAPIError(error_msg, response.status_code, response.text)
|
|
574
|
-
|
|
575
|
-
if table_name not in self._guid_mapping:
|
|
576
|
-
update_mapping()
|
|
577
|
-
|
|
578
|
-
if name:
|
|
579
|
-
guid = self._guid_mapping[table_name].get(name, None)
|
|
580
|
-
if not guid:
|
|
581
|
-
raise EntityError(f"{name} not found in {table_name}")
|
|
582
|
-
else:
|
|
583
|
-
return guid
|
|
584
|
-
else:
|
|
585
|
-
return None
|
|
586
660
|
|
|
587
|
-
|
|
588
|
-
return next(
|
|
589
|
-
(
|
|
590
|
-
key
|
|
591
|
-
for key, value in self._guid_mapping.items()
|
|
592
|
-
if guid in [guid for guid in value.values()]
|
|
593
|
-
),
|
|
594
|
-
None,
|
|
595
|
-
)
|
|
596
|
-
|
|
597
|
-
def get_table_entity_set_name(self, schema_name=None, logical_name=None):
|
|
598
|
-
tables = self.get_table_definitions()
|
|
599
|
-
|
|
600
|
-
if schema_name:
|
|
601
|
-
key = "SchemaName"
|
|
602
|
-
name = schema_name
|
|
603
|
-
elif logical_name:
|
|
604
|
-
key = "LogicalName"
|
|
605
|
-
name = logical_name
|
|
606
|
-
else:
|
|
607
|
-
raise ValueError("Please input schema or logical name")
|
|
608
|
-
|
|
609
|
-
for entity in tables["value"]:
|
|
610
|
-
if name == entity[key]:
|
|
611
|
-
return entity["EntitySetName"]
|
|
612
|
-
raise EntityError(f"{key} not found for {name}")
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
def is_valid_guid(value: str) -> bool:
|
|
616
|
-
"""Returns True if the value is a valid GUID, otherwise False."""
|
|
617
|
-
try:
|
|
618
|
-
uuid_obj = uuid.UUID(value) # Ensure it's a valid UUID
|
|
619
|
-
return str(uuid_obj) == value.lower() # Ensure correct format
|
|
620
|
-
except ValueError:
|
|
621
|
-
return False
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
class DataverseTable:
|
|
661
|
+
class DataverseEntity(DataverseBase):
|
|
625
662
|
"""Generic class for Dataverse entities"""
|
|
626
663
|
|
|
627
|
-
def __init__(self, table_logical_name, table_prefix="
|
|
664
|
+
def __init__(self, table_logical_name, table_prefix="", client=None, **kwargs):
|
|
628
665
|
"""
|
|
629
666
|
Args:
|
|
630
667
|
table_logical_name: The pluralized name of the Dataverse table
|
|
631
668
|
table_prefix: The prefix used for custom table/column names (default: "prefix_")
|
|
669
|
+
client: Optional DataverseClient instance (will create singleton if not provided)
|
|
670
|
+
**kwargs: Additional options including suppress_property_generation for testing
|
|
632
671
|
"""
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
table_logical_name = str(table_logical_name)
|
|
636
|
-
self.logical_name = table_logical_name
|
|
637
|
-
self.table_prefix = table_prefix
|
|
638
|
-
self.table_name = table_logical_name.replace(self.table_prefix, "").title()
|
|
639
|
-
self.col_metadata = self.dataverse.get_table_metadata(self.logical_name)
|
|
640
|
-
# Filter global choices to only include those relevant to our prefix
|
|
641
|
-
all_choices = self.dataverse.global_choices
|
|
642
|
-
prefix_filter = self.table_prefix.rstrip("_")
|
|
643
|
-
self.choices = {
|
|
644
|
-
name: options for name, options in all_choices.items() if prefix_filter in name
|
|
645
|
-
}
|
|
646
|
-
self.relationships = self.get_relationships()
|
|
672
|
+
super().__init__(table_logical_name, table_prefix, client)
|
|
673
|
+
self.client = client or DataverseClient()
|
|
647
674
|
|
|
648
675
|
self.guid = None
|
|
649
676
|
self.data = {} # Dictionary to store record fields
|
|
677
|
+
self._properties_generated = False
|
|
650
678
|
|
|
651
|
-
#
|
|
652
|
-
|
|
679
|
+
# Generate properties unless suppressed (useful for testing)
|
|
680
|
+
if not kwargs.get("suppress_property_generation", False):
|
|
681
|
+
self._generate_properties()
|
|
653
682
|
|
|
654
683
|
def _generate_properties(self):
|
|
655
684
|
"""Automatically generates properties for all valid columns in the table"""
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
# Skip system fields and non-user fields
|
|
659
|
-
if not column_logical_name.startswith(self.table_prefix):
|
|
660
|
-
continue
|
|
661
|
-
|
|
662
|
-
# File and Virtual columns often have IsValidForCreate/Update = False, so allow them through
|
|
663
|
-
field_type = metadata.get("AttributeType")
|
|
664
|
-
if (
|
|
665
|
-
field_type not in ["File", "Virtual"]
|
|
666
|
-
and not metadata.get("IsValidForCreate")
|
|
667
|
-
and not metadata.get("IsValidForUpdate")
|
|
668
|
-
):
|
|
669
|
-
continue
|
|
685
|
+
if self._properties_generated:
|
|
686
|
+
return
|
|
670
687
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
688
|
+
logger.info(f"Generating properties for {self.table_logical_name}")
|
|
689
|
+
try:
|
|
690
|
+
for column_logical_name, metadata in self.get_table_metadata().items():
|
|
691
|
+
# Skip system fields and non-user fields
|
|
692
|
+
if not column_logical_name.startswith(self.table_logical_name_prefix):
|
|
693
|
+
continue
|
|
694
|
+
|
|
695
|
+
# File and Virtual columns often have IsValidForCreate/Update = False, so allow them through
|
|
696
|
+
field_type = metadata.get("AttributeType")
|
|
697
|
+
if (
|
|
698
|
+
field_type not in ["File", "Virtual"]
|
|
699
|
+
and not metadata.get("IsValidForCreate")
|
|
700
|
+
and not metadata.get("IsValidForUpdate")
|
|
701
|
+
):
|
|
702
|
+
continue
|
|
703
|
+
|
|
704
|
+
# Generate property name by removing prefix
|
|
705
|
+
if column_logical_name.startswith(self.table_logical_name_prefix):
|
|
706
|
+
property_name = column_logical_name.replace(
|
|
707
|
+
self.table_logical_name_prefix, "", 1
|
|
708
|
+
) # Remove prefix
|
|
709
|
+
else:
|
|
710
|
+
property_name = column_logical_name
|
|
711
|
+
|
|
712
|
+
# Determine property type based on field metadata
|
|
713
|
+
field_type = metadata.get("AttributeType")
|
|
714
|
+
|
|
715
|
+
if field_type == "File" or (
|
|
716
|
+
field_type == "Virtual" and "json" in column_logical_name.lower()
|
|
717
|
+
):
|
|
718
|
+
# This is a file field or virtual file field - check this FIRST
|
|
719
|
+
logger.info(f"Setting {property_name} as file property")
|
|
720
|
+
setattr(self.__class__, property_name, self.file_property(column_logical_name))
|
|
721
|
+
elif column_logical_name in self.get_table_relationships():
|
|
722
|
+
# This is a lookup field
|
|
723
|
+
logger.info(f"Setting {property_name} as lookup property")
|
|
724
|
+
setattr(
|
|
725
|
+
self.__class__, property_name, self.lookup_property(column_logical_name)
|
|
726
|
+
)
|
|
727
|
+
elif column_logical_name in self.filtered_choices:
|
|
728
|
+
# This is a choice field
|
|
729
|
+
logger.info(f"Setting {property_name} as choice property")
|
|
730
|
+
setattr(
|
|
731
|
+
self.__class__, property_name, self.choice_property(column_logical_name)
|
|
732
|
+
)
|
|
733
|
+
elif field_type == "DateTime":
|
|
734
|
+
# This is a date and time field
|
|
735
|
+
logger.info(f"Setting {property_name} as datetime property")
|
|
736
|
+
setattr(self.__class__, property_name, self.data_property(column_logical_name))
|
|
737
|
+
else:
|
|
738
|
+
# This is a regular data field
|
|
739
|
+
logger.info(f"Setting {property_name} as data property (fallback)")
|
|
740
|
+
setattr(self.__class__, property_name, self.data_property(column_logical_name))
|
|
709
741
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
_relationships[rel["ReferencingAttribute"]] = rel["ReferencedEntity"]
|
|
716
|
-
return _relationships
|
|
742
|
+
self._properties_generated = True
|
|
743
|
+
logger.info(f"Property generation completed for {self.table_logical_name}")
|
|
744
|
+
except Exception as e:
|
|
745
|
+
logger.warning(f"Property generation failed for {self.table_logical_name}: {e}")
|
|
746
|
+
# Don't raise the exception - entity should still be usable without dynamic properties
|
|
717
747
|
|
|
718
748
|
# === FREE TEXT COLUMNS ===
|
|
719
749
|
def set_data(self, column, value):
|
|
@@ -723,17 +753,17 @@ class DataverseTable:
|
|
|
723
753
|
def get_data(self, column):
|
|
724
754
|
value = self.data.get(column, "")
|
|
725
755
|
|
|
726
|
-
# If value is empty but we have a GUID, try to fetch from Dataverse
|
|
756
|
+
# If value is empty, but we have a GUID, try to fetch from Dataverse
|
|
727
757
|
if not value and self.guid:
|
|
728
758
|
try:
|
|
729
|
-
entity_set_name = self.
|
|
730
|
-
logical_name=self.
|
|
759
|
+
entity_set_name = self.get_table_entity_set_name(
|
|
760
|
+
logical_name=self.table_logical_name
|
|
731
761
|
)
|
|
732
|
-
record = self.
|
|
762
|
+
record = self.get_record(entity_set_name, self.guid)
|
|
733
763
|
|
|
734
764
|
# Update our data with the fresh record
|
|
735
765
|
for field, fresh_value in record.items():
|
|
736
|
-
if field.startswith(self.
|
|
766
|
+
if field.startswith(self.table_logical_name_prefix) or field.endswith("id"):
|
|
737
767
|
self.data[field] = fresh_value
|
|
738
768
|
|
|
739
769
|
# Return the requested value
|
|
@@ -757,23 +787,37 @@ class DataverseTable:
|
|
|
757
787
|
return False
|
|
758
788
|
|
|
759
789
|
try:
|
|
760
|
-
entity_set_name = self.
|
|
761
|
-
|
|
762
|
-
)
|
|
763
|
-
record = self.dataverse.get_record(entity_set_name, self.guid)
|
|
790
|
+
entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
|
|
791
|
+
record = self.get_record(entity_set_name, self.guid)
|
|
764
792
|
|
|
765
793
|
# Update our data with the fresh record
|
|
766
794
|
for field, fresh_value in record.items():
|
|
767
|
-
if field.startswith(self.
|
|
795
|
+
if field.startswith(self.table_logical_name_prefix) or field.endswith("id"):
|
|
768
796
|
self.data[field] = fresh_value
|
|
769
797
|
|
|
770
|
-
logger.info(f"Refreshed data for {self.
|
|
798
|
+
logger.info(f"Refreshed data for {self.table_logical_name} GUID: {self.guid}")
|
|
771
799
|
return True
|
|
772
800
|
|
|
773
801
|
except Exception as e:
|
|
774
|
-
logger.error(
|
|
802
|
+
logger.error(
|
|
803
|
+
f"Failed to refresh data for {self.table_logical_name} GUID {self.guid}: {e}"
|
|
804
|
+
)
|
|
775
805
|
return False
|
|
776
806
|
|
|
807
|
+
def fetch_data(self, entity_id=None):
|
|
808
|
+
"""
|
|
809
|
+
Fetch data for an existing entity from Dataverse by its ID.
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
entity_id: The GUID of the entity to fetch
|
|
813
|
+
|
|
814
|
+
Returns:
|
|
815
|
+
bool: True if fetch was successful, False otherwise
|
|
816
|
+
"""
|
|
817
|
+
if entity_id is not None:
|
|
818
|
+
self.guid = entity_id
|
|
819
|
+
return self.refresh()
|
|
820
|
+
|
|
777
821
|
@staticmethod
|
|
778
822
|
def data_property(column):
|
|
779
823
|
def getter(self):
|
|
@@ -787,15 +831,15 @@ class DataverseTable:
|
|
|
787
831
|
# === LOOKUP
|
|
788
832
|
def set_lookup(self, lookup_column, value: str):
|
|
789
833
|
"""Adds a lookup reference using @odata.bind"""
|
|
790
|
-
lookup_column_schema_name = self.
|
|
834
|
+
lookup_column_schema_name = self.get_table_metadata()[lookup_column]["SchemaName"]
|
|
791
835
|
|
|
792
|
-
target_entity = self.
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
)
|
|
836
|
+
target_entity = self.get_table_relationships().get(
|
|
837
|
+
lookup_column
|
|
838
|
+
) # Get table of lookup column
|
|
839
|
+
target_entity_set_name = self.get_table_entity_set_name(logical_name=target_entity)
|
|
796
840
|
|
|
797
841
|
if not is_valid_guid(value):
|
|
798
|
-
value = self.
|
|
842
|
+
value = self.name_to_guid(target_entity, value)
|
|
799
843
|
|
|
800
844
|
self.data[f"{lookup_column_schema_name}@odata.bind"] = f"/{target_entity_set_name}({value})"
|
|
801
845
|
|
|
@@ -819,7 +863,7 @@ class DataverseTable:
|
|
|
819
863
|
# === CHOICE
|
|
820
864
|
def set_choice(self, column, value):
|
|
821
865
|
"""Adds a choice field by looking up its numeric value"""
|
|
822
|
-
choice_value = self.
|
|
866
|
+
choice_value = self.filtered_choices[column].get(value) # Get numeric value for choice
|
|
823
867
|
if choice_value is not None:
|
|
824
868
|
self.data[column] = choice_value
|
|
825
869
|
else:
|
|
@@ -829,7 +873,10 @@ class DataverseTable:
|
|
|
829
873
|
|
|
830
874
|
def get_choice(self, column):
|
|
831
875
|
"""Retrieves the readable choice name from its stored numeric value (Int → String)"""
|
|
832
|
-
|
|
876
|
+
choice_dict = self.filtered_choices.get(column, {})
|
|
877
|
+
choices_invert = {v: k for k, v in choice_dict.items()} # Invert dict for reverse lookup
|
|
878
|
+
numeric_value = self.data.get(column, None)
|
|
879
|
+
return choices_invert.get(numeric_value, None)
|
|
833
880
|
|
|
834
881
|
@staticmethod
|
|
835
882
|
def choice_property(lookup_column):
|
|
@@ -844,11 +891,11 @@ class DataverseTable:
|
|
|
844
891
|
if not self.guid:
|
|
845
892
|
raise EntityError("Record must be created first (GUID required)")
|
|
846
893
|
|
|
847
|
-
entity_set_name = self.
|
|
848
|
-
base_url = f"{self.
|
|
894
|
+
entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
|
|
895
|
+
base_url = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})"
|
|
849
896
|
|
|
850
897
|
# Step 1: PATCH metadata
|
|
851
|
-
meta_headers = self.
|
|
898
|
+
meta_headers = self.client.session.headers.copy()
|
|
852
899
|
meta_headers["Content-Type"] = "application/json"
|
|
853
900
|
|
|
854
901
|
metadata_payload = {
|
|
@@ -915,18 +962,18 @@ class DataverseTable:
|
|
|
915
962
|
if not self.guid:
|
|
916
963
|
return None
|
|
917
964
|
|
|
918
|
-
entity_set_name = self.
|
|
919
|
-
return f"{self.
|
|
965
|
+
entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
|
|
966
|
+
return f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})/{column}/$value"
|
|
920
967
|
|
|
921
968
|
def get_file(self, column):
|
|
922
969
|
"""Downloads and returns file content from file columns"""
|
|
923
970
|
if not self.guid:
|
|
924
971
|
return None
|
|
925
972
|
|
|
926
|
-
entity_set_name = self.
|
|
927
|
-
file_url = f"{self.
|
|
973
|
+
entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
|
|
974
|
+
file_url = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})/{column}/$value"
|
|
928
975
|
|
|
929
|
-
response = self.
|
|
976
|
+
response = self.client.session.get(file_url)
|
|
930
977
|
|
|
931
978
|
if response.status_code == 200:
|
|
932
979
|
# For JSON files, try to parse and return as dict
|
|
@@ -963,12 +1010,12 @@ class DataverseTable:
|
|
|
963
1010
|
validated_data[field] = value
|
|
964
1011
|
continue
|
|
965
1012
|
|
|
966
|
-
if field not in self.
|
|
1013
|
+
if field not in self.get_table_metadata():
|
|
967
1014
|
# Field not in metadata, pass through as-is
|
|
968
1015
|
validated_data[field] = value
|
|
969
1016
|
continue
|
|
970
1017
|
|
|
971
|
-
field_type = self.
|
|
1018
|
+
field_type = self.get_table_metadata()[field].get("AttributeType")
|
|
972
1019
|
|
|
973
1020
|
if field_type == "Decimal" and isinstance(value, (int, float, str)):
|
|
974
1021
|
# Convert to proper decimal format for Dataverse
|
|
@@ -999,24 +1046,40 @@ class DataverseTable:
|
|
|
999
1046
|
if self.guid:
|
|
1000
1047
|
return self._update_record()
|
|
1001
1048
|
|
|
1002
|
-
# ===
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1049
|
+
# === OPTIONAL DUPLICATE CHECK (warn if potential name-based duplicate exists) ===
|
|
1050
|
+
name_columns = [col for col in self.data.keys() if "name" in col.lower()]
|
|
1051
|
+
if name_columns:
|
|
1052
|
+
try:
|
|
1053
|
+
# Simple check for existing record with same name (warning only)
|
|
1054
|
+
name_column = name_columns[0]
|
|
1055
|
+
name_value = self.data[name_column]
|
|
1056
|
+
guid_column = self.table_logical_name + "id"
|
|
1057
|
+
|
|
1058
|
+
entity_set_name = self.get_table_entity_set_name(
|
|
1059
|
+
logical_name=self.table_logical_name
|
|
1010
1060
|
)
|
|
1011
|
-
|
|
1061
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}?$select={name_column},{guid_column}&$filter={name_column} eq '{name_value}'"
|
|
1062
|
+
|
|
1063
|
+
response = self.client.session.get(request_uri)
|
|
1064
|
+
if response.status_code == 200:
|
|
1065
|
+
records = response.json().get("value", [])
|
|
1066
|
+
if records:
|
|
1067
|
+
existing_guid = records[0].get(guid_column)
|
|
1068
|
+
logger.warning(
|
|
1069
|
+
f"⚠️ Record with name '{name_value}' may already exist in {self.table_logical_name}. "
|
|
1070
|
+
f"Existing GUID: {existing_guid}. Creating new record anyway."
|
|
1071
|
+
)
|
|
1072
|
+
except Exception as e:
|
|
1073
|
+
# If duplicate check fails, just continue with creation
|
|
1074
|
+
logger.info(f"Duplicate check skipped due to error: {e}")
|
|
1012
1075
|
|
|
1013
1076
|
# This is a CREATE operation
|
|
1014
1077
|
return self._create_record()
|
|
1015
1078
|
|
|
1016
|
-
def _create_record(self):
|
|
1079
|
+
def _create_record(self) -> uuid.UUID | None:
|
|
1017
1080
|
"""Creates a new record in Dataverse"""
|
|
1018
|
-
entity_set_name = self.
|
|
1019
|
-
request_uri = f"{self.
|
|
1081
|
+
entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
|
|
1082
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}"
|
|
1020
1083
|
|
|
1021
1084
|
# Validate and convert data types before sending
|
|
1022
1085
|
validated_data = self._validate_and_convert_data(self.data)
|
|
@@ -1030,12 +1093,12 @@ class DataverseTable:
|
|
|
1030
1093
|
"POST",
|
|
1031
1094
|
request_uri,
|
|
1032
1095
|
data=record_json,
|
|
1033
|
-
headers={**self.
|
|
1096
|
+
headers={**self.client.session.headers, "Content-Type": "application/json"},
|
|
1034
1097
|
).prepare()
|
|
1035
|
-
response = self.
|
|
1098
|
+
response = self.client.session.send(req)
|
|
1036
1099
|
|
|
1037
1100
|
if response.status_code == 204:
|
|
1038
|
-
logger.info(f"✅ Record created in {self.
|
|
1101
|
+
logger.info(f"✅ Record created in {self.table_logical_name} successfully!")
|
|
1039
1102
|
|
|
1040
1103
|
# Extract GUID from OData-EntityId header
|
|
1041
1104
|
created_record_url = response.headers.get("OData-EntityId")
|
|
@@ -1044,19 +1107,21 @@ class DataverseTable:
|
|
|
1044
1107
|
logger.info(f"🆔 Assigned GUID: {self.guid}")
|
|
1045
1108
|
|
|
1046
1109
|
# Update guid mapping
|
|
1047
|
-
self.
|
|
1110
|
+
self.name_to_guid(self.table_logical_name)
|
|
1048
1111
|
return self.guid
|
|
1112
|
+
return None
|
|
1049
1113
|
else:
|
|
1050
1114
|
# Enhanced error message with field information
|
|
1051
|
-
error_details = [
|
|
1052
|
-
|
|
1053
|
-
|
|
1115
|
+
error_details = [
|
|
1116
|
+
f"Table: {entity_set_name}",
|
|
1117
|
+
f"Record data sent: {json.dumps(record, indent=2)}",
|
|
1118
|
+
]
|
|
1054
1119
|
|
|
1055
1120
|
# Analyze data types for decimal conversion issues
|
|
1056
1121
|
decimal_fields = []
|
|
1057
1122
|
for field, value in record.items():
|
|
1058
|
-
if isinstance(value, (int, float)) and field in self.
|
|
1059
|
-
field_type = self.
|
|
1123
|
+
if isinstance(value, (int, float)) and field in self.get_table_metadata():
|
|
1124
|
+
field_type = self.get_table_metadata()[field].get("AttributeType")
|
|
1060
1125
|
if field_type == "Decimal":
|
|
1061
1126
|
decimal_fields.append(
|
|
1062
1127
|
f" {field}: {value} (type: {type(value).__name__}, expected: Decimal)"
|
|
@@ -1075,10 +1140,8 @@ class DataverseTable:
|
|
|
1075
1140
|
|
|
1076
1141
|
def _update_record(self):
|
|
1077
1142
|
"""Updates an existing record in Dataverse"""
|
|
1078
|
-
entity_set_name = self.
|
|
1079
|
-
request_uri = (
|
|
1080
|
-
f"{self.dataverse.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})"
|
|
1081
|
-
)
|
|
1143
|
+
entity_set_name = self.get_table_entity_set_name(logical_name=self.table_logical_name)
|
|
1144
|
+
request_uri = f"{self.client.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})"
|
|
1082
1145
|
|
|
1083
1146
|
# Validate and convert data types before sending
|
|
1084
1147
|
validated_data = self._validate_and_convert_data(self.data)
|
|
@@ -1094,7 +1157,7 @@ class DataverseTable:
|
|
|
1094
1157
|
update_data[field] = value
|
|
1095
1158
|
|
|
1096
1159
|
if not update_data:
|
|
1097
|
-
logger.info(f"No data to update for {self.
|
|
1160
|
+
logger.info(f"No data to update for {self.table_logical_name} GUID: {self.guid}")
|
|
1098
1161
|
return self.guid
|
|
1099
1162
|
|
|
1100
1163
|
# Send update to Dataverse (convert Decimals to floats)
|
|
@@ -1103,12 +1166,12 @@ class DataverseTable:
|
|
|
1103
1166
|
"PATCH",
|
|
1104
1167
|
request_uri,
|
|
1105
1168
|
data=update_json,
|
|
1106
|
-
headers={**self.
|
|
1169
|
+
headers={**self.client.session.headers, "Content-Type": "application/json"},
|
|
1107
1170
|
).prepare()
|
|
1108
|
-
response = self.
|
|
1171
|
+
response = self.client.session.send(req)
|
|
1109
1172
|
|
|
1110
1173
|
if response.status_code == 204:
|
|
1111
|
-
logger.info(f"✅ Record updated in {self.
|
|
1174
|
+
logger.info(f"✅ Record updated in {self.table_logical_name} successfully!")
|
|
1112
1175
|
return self.guid
|
|
1113
1176
|
else:
|
|
1114
1177
|
error_msg = f"Error updating record in {entity_set_name}: {response.text}"
|
|
File without changes
|