surfdataverse 2.0.1__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.0.1 → surfdataverse-3.0.0}/PKG-INFO +6 -3
- {surfdataverse-2.0.1 → surfdataverse-3.0.0}/README.md +3 -2
- {surfdataverse-2.0.1 → surfdataverse-3.0.0}/pyproject.toml +3 -1
- {surfdataverse-2.0.1 → surfdataverse-3.0.0}/src/surfdataverse/__init__.py +8 -1
- surfdataverse-3.0.0/src/surfdataverse/container.py +49 -0
- {surfdataverse-2.0.1 → surfdataverse-3.0.0}/src/surfdataverse/core.py +433 -366
- {surfdataverse-2.0.1 → 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,266 +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
|
-
|
|
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))
|
|
705
741
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
_relationships[rel["ReferencingAttribute"]] = rel["ReferencedEntity"]
|
|
712
|
-
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
|
|
713
747
|
|
|
714
748
|
# === FREE TEXT COLUMNS ===
|
|
715
749
|
def set_data(self, column, value):
|
|
@@ -719,17 +753,17 @@ class DataverseTable:
|
|
|
719
753
|
def get_data(self, column):
|
|
720
754
|
value = self.data.get(column, "")
|
|
721
755
|
|
|
722
|
-
# 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
|
|
723
757
|
if not value and self.guid:
|
|
724
758
|
try:
|
|
725
|
-
entity_set_name = self.
|
|
726
|
-
logical_name=self.
|
|
759
|
+
entity_set_name = self.get_table_entity_set_name(
|
|
760
|
+
logical_name=self.table_logical_name
|
|
727
761
|
)
|
|
728
|
-
record = self.
|
|
762
|
+
record = self.get_record(entity_set_name, self.guid)
|
|
729
763
|
|
|
730
764
|
# Update our data with the fresh record
|
|
731
765
|
for field, fresh_value in record.items():
|
|
732
|
-
if field.startswith(self.
|
|
766
|
+
if field.startswith(self.table_logical_name_prefix) or field.endswith("id"):
|
|
733
767
|
self.data[field] = fresh_value
|
|
734
768
|
|
|
735
769
|
# Return the requested value
|
|
@@ -753,23 +787,37 @@ class DataverseTable:
|
|
|
753
787
|
return False
|
|
754
788
|
|
|
755
789
|
try:
|
|
756
|
-
entity_set_name = self.
|
|
757
|
-
|
|
758
|
-
)
|
|
759
|
-
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)
|
|
760
792
|
|
|
761
793
|
# Update our data with the fresh record
|
|
762
794
|
for field, fresh_value in record.items():
|
|
763
|
-
if field.startswith(self.
|
|
795
|
+
if field.startswith(self.table_logical_name_prefix) or field.endswith("id"):
|
|
764
796
|
self.data[field] = fresh_value
|
|
765
797
|
|
|
766
|
-
logger.info(f"Refreshed data for {self.
|
|
798
|
+
logger.info(f"Refreshed data for {self.table_logical_name} GUID: {self.guid}")
|
|
767
799
|
return True
|
|
768
800
|
|
|
769
801
|
except Exception as e:
|
|
770
|
-
logger.error(
|
|
802
|
+
logger.error(
|
|
803
|
+
f"Failed to refresh data for {self.table_logical_name} GUID {self.guid}: {e}"
|
|
804
|
+
)
|
|
771
805
|
return False
|
|
772
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
|
+
|
|
773
821
|
@staticmethod
|
|
774
822
|
def data_property(column):
|
|
775
823
|
def getter(self):
|
|
@@ -783,15 +831,15 @@ class DataverseTable:
|
|
|
783
831
|
# === LOOKUP
|
|
784
832
|
def set_lookup(self, lookup_column, value: str):
|
|
785
833
|
"""Adds a lookup reference using @odata.bind"""
|
|
786
|
-
lookup_column_schema_name = self.
|
|
834
|
+
lookup_column_schema_name = self.get_table_metadata()[lookup_column]["SchemaName"]
|
|
787
835
|
|
|
788
|
-
target_entity = self.
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
)
|
|
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)
|
|
792
840
|
|
|
793
841
|
if not is_valid_guid(value):
|
|
794
|
-
value = self.
|
|
842
|
+
value = self.name_to_guid(target_entity, value)
|
|
795
843
|
|
|
796
844
|
self.data[f"{lookup_column_schema_name}@odata.bind"] = f"/{target_entity_set_name}({value})"
|
|
797
845
|
|
|
@@ -815,7 +863,7 @@ class DataverseTable:
|
|
|
815
863
|
# === CHOICE
|
|
816
864
|
def set_choice(self, column, value):
|
|
817
865
|
"""Adds a choice field by looking up its numeric value"""
|
|
818
|
-
choice_value = self.
|
|
866
|
+
choice_value = self.filtered_choices[column].get(value) # Get numeric value for choice
|
|
819
867
|
if choice_value is not None:
|
|
820
868
|
self.data[column] = choice_value
|
|
821
869
|
else:
|
|
@@ -825,7 +873,10 @@ class DataverseTable:
|
|
|
825
873
|
|
|
826
874
|
def get_choice(self, column):
|
|
827
875
|
"""Retrieves the readable choice name from its stored numeric value (Int → String)"""
|
|
828
|
-
|
|
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)
|
|
829
880
|
|
|
830
881
|
@staticmethod
|
|
831
882
|
def choice_property(lookup_column):
|
|
@@ -840,11 +891,11 @@ class DataverseTable:
|
|
|
840
891
|
if not self.guid:
|
|
841
892
|
raise EntityError("Record must be created first (GUID required)")
|
|
842
893
|
|
|
843
|
-
entity_set_name = self.
|
|
844
|
-
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})"
|
|
845
896
|
|
|
846
897
|
# Step 1: PATCH metadata
|
|
847
|
-
meta_headers = self.
|
|
898
|
+
meta_headers = self.client.session.headers.copy()
|
|
848
899
|
meta_headers["Content-Type"] = "application/json"
|
|
849
900
|
|
|
850
901
|
metadata_payload = {
|
|
@@ -911,18 +962,18 @@ class DataverseTable:
|
|
|
911
962
|
if not self.guid:
|
|
912
963
|
return None
|
|
913
964
|
|
|
914
|
-
entity_set_name = self.
|
|
915
|
-
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"
|
|
916
967
|
|
|
917
968
|
def get_file(self, column):
|
|
918
969
|
"""Downloads and returns file content from file columns"""
|
|
919
970
|
if not self.guid:
|
|
920
971
|
return None
|
|
921
972
|
|
|
922
|
-
entity_set_name = self.
|
|
923
|
-
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"
|
|
924
975
|
|
|
925
|
-
response = self.
|
|
976
|
+
response = self.client.session.get(file_url)
|
|
926
977
|
|
|
927
978
|
if response.status_code == 200:
|
|
928
979
|
# For JSON files, try to parse and return as dict
|
|
@@ -959,12 +1010,12 @@ class DataverseTable:
|
|
|
959
1010
|
validated_data[field] = value
|
|
960
1011
|
continue
|
|
961
1012
|
|
|
962
|
-
if field not in self.
|
|
1013
|
+
if field not in self.get_table_metadata():
|
|
963
1014
|
# Field not in metadata, pass through as-is
|
|
964
1015
|
validated_data[field] = value
|
|
965
1016
|
continue
|
|
966
1017
|
|
|
967
|
-
field_type = self.
|
|
1018
|
+
field_type = self.get_table_metadata()[field].get("AttributeType")
|
|
968
1019
|
|
|
969
1020
|
if field_type == "Decimal" and isinstance(value, (int, float, str)):
|
|
970
1021
|
# Convert to proper decimal format for Dataverse
|
|
@@ -995,24 +1046,40 @@ class DataverseTable:
|
|
|
995
1046
|
if self.guid:
|
|
996
1047
|
return self._update_record()
|
|
997
1048
|
|
|
998
|
-
# ===
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
|
1006
1060
|
)
|
|
1007
|
-
|
|
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}")
|
|
1008
1075
|
|
|
1009
1076
|
# This is a CREATE operation
|
|
1010
1077
|
return self._create_record()
|
|
1011
1078
|
|
|
1012
|
-
def _create_record(self):
|
|
1079
|
+
def _create_record(self) -> uuid.UUID | None:
|
|
1013
1080
|
"""Creates a new record in Dataverse"""
|
|
1014
|
-
entity_set_name = self.
|
|
1015
|
-
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}"
|
|
1016
1083
|
|
|
1017
1084
|
# Validate and convert data types before sending
|
|
1018
1085
|
validated_data = self._validate_and_convert_data(self.data)
|
|
@@ -1026,12 +1093,12 @@ class DataverseTable:
|
|
|
1026
1093
|
"POST",
|
|
1027
1094
|
request_uri,
|
|
1028
1095
|
data=record_json,
|
|
1029
|
-
headers={**self.
|
|
1096
|
+
headers={**self.client.session.headers, "Content-Type": "application/json"},
|
|
1030
1097
|
).prepare()
|
|
1031
|
-
response = self.
|
|
1098
|
+
response = self.client.session.send(req)
|
|
1032
1099
|
|
|
1033
1100
|
if response.status_code == 204:
|
|
1034
|
-
logger.info(f"✅ Record created in {self.
|
|
1101
|
+
logger.info(f"✅ Record created in {self.table_logical_name} successfully!")
|
|
1035
1102
|
|
|
1036
1103
|
# Extract GUID from OData-EntityId header
|
|
1037
1104
|
created_record_url = response.headers.get("OData-EntityId")
|
|
@@ -1040,19 +1107,21 @@ class DataverseTable:
|
|
|
1040
1107
|
logger.info(f"🆔 Assigned GUID: {self.guid}")
|
|
1041
1108
|
|
|
1042
1109
|
# Update guid mapping
|
|
1043
|
-
self.
|
|
1110
|
+
self.name_to_guid(self.table_logical_name)
|
|
1044
1111
|
return self.guid
|
|
1112
|
+
return None
|
|
1045
1113
|
else:
|
|
1046
1114
|
# Enhanced error message with field information
|
|
1047
|
-
error_details = [
|
|
1048
|
-
|
|
1049
|
-
|
|
1115
|
+
error_details = [
|
|
1116
|
+
f"Table: {entity_set_name}",
|
|
1117
|
+
f"Record data sent: {json.dumps(record, indent=2)}",
|
|
1118
|
+
]
|
|
1050
1119
|
|
|
1051
1120
|
# Analyze data types for decimal conversion issues
|
|
1052
1121
|
decimal_fields = []
|
|
1053
1122
|
for field, value in record.items():
|
|
1054
|
-
if isinstance(value, (int, float)) and field in self.
|
|
1055
|
-
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")
|
|
1056
1125
|
if field_type == "Decimal":
|
|
1057
1126
|
decimal_fields.append(
|
|
1058
1127
|
f" {field}: {value} (type: {type(value).__name__}, expected: Decimal)"
|
|
@@ -1071,10 +1140,8 @@ class DataverseTable:
|
|
|
1071
1140
|
|
|
1072
1141
|
def _update_record(self):
|
|
1073
1142
|
"""Updates an existing record in Dataverse"""
|
|
1074
|
-
entity_set_name = self.
|
|
1075
|
-
request_uri = (
|
|
1076
|
-
f"{self.dataverse.environment_uri}api/data/v9.2/{entity_set_name}({self.guid})"
|
|
1077
|
-
)
|
|
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})"
|
|
1078
1145
|
|
|
1079
1146
|
# Validate and convert data types before sending
|
|
1080
1147
|
validated_data = self._validate_and_convert_data(self.data)
|
|
@@ -1090,7 +1157,7 @@ class DataverseTable:
|
|
|
1090
1157
|
update_data[field] = value
|
|
1091
1158
|
|
|
1092
1159
|
if not update_data:
|
|
1093
|
-
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}")
|
|
1094
1161
|
return self.guid
|
|
1095
1162
|
|
|
1096
1163
|
# Send update to Dataverse (convert Decimals to floats)
|
|
@@ -1099,12 +1166,12 @@ class DataverseTable:
|
|
|
1099
1166
|
"PATCH",
|
|
1100
1167
|
request_uri,
|
|
1101
1168
|
data=update_json,
|
|
1102
|
-
headers={**self.
|
|
1169
|
+
headers={**self.client.session.headers, "Content-Type": "application/json"},
|
|
1103
1170
|
).prepare()
|
|
1104
|
-
response = self.
|
|
1171
|
+
response = self.client.session.send(req)
|
|
1105
1172
|
|
|
1106
1173
|
if response.status_code == 204:
|
|
1107
|
-
logger.info(f"✅ Record updated in {self.
|
|
1174
|
+
logger.info(f"✅ Record updated in {self.table_logical_name} successfully!")
|
|
1108
1175
|
return self.guid
|
|
1109
1176
|
else:
|
|
1110
1177
|
error_msg = f"Error updating record in {entity_set_name}: {response.text}"
|
|
File without changes
|