msdev-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msdev_kit/__init__.py +3 -0
- msdev_kit/auth.py +35 -0
- msdev_kit/fabric/__init__.py +11 -0
- msdev_kit/fabric/admin.py +42 -0
- msdev_kit/fabric/capacity.py +186 -0
- msdev_kit/fabric/database.py +94 -0
- msdev_kit/fabric/dataflow.py +1801 -0
- msdev_kit/fabric/dataset.py +617 -0
- msdev_kit/fabric/kql.py +66 -0
- msdev_kit/fabric/notebook.py +88 -0
- msdev_kit/fabric/operations.py +108 -0
- msdev_kit/fabric/pipeline.py +505 -0
- msdev_kit/fabric/report.py +1012 -0
- msdev_kit/fabric/utilities.py +12 -0
- msdev_kit/fabric/workspace.py +516 -0
- msdev_kit/graph/__init__.py +1 -0
- msdev_kit/graph/client.py +91 -0
- msdev_kit/sharepoint/__init__.py +1 -0
- msdev_kit/sharepoint/client.py +105 -0
- msdev_kit-0.1.0.dist-info/METADATA +269 -0
- msdev_kit-0.1.0.dist-info/RECORD +22 -0
- msdev_kit-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import base64
|
|
4
|
+
import requests
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from time import sleep
|
|
7
|
+
from .operations import Operations
|
|
8
|
+
from typing import Dict, List, Any
|
|
9
|
+
from .utilities import create_directory
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from . import admin as admin_module
|
|
12
|
+
from . import dataset as dataset_module
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Report:
|
|
16
|
+
|
|
17
|
+
def __init__(self, token: str):
|
|
18
|
+
"""
|
|
19
|
+
Initialize variables.
|
|
20
|
+
"""
|
|
21
|
+
self.main_url = 'https://api.powerbi.com/v1.0/myorg'
|
|
22
|
+
self.main_fabric_url = 'https://api.fabric.microsoft.com/v1'
|
|
23
|
+
self.token = token
|
|
24
|
+
self.headers = {'Authorization': f'Bearer {self.token}'}
|
|
25
|
+
self.data_dir = './data/reports'
|
|
26
|
+
|
|
27
|
+
create_directory(self.data_dir)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_reports(
|
|
31
|
+
self,
|
|
32
|
+
workspace_id: str = '') -> Dict:
|
|
33
|
+
"""
|
|
34
|
+
List all reports on a specific workspace_id that the user has access to.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
workspace_id (str, optional): workspace id to search reports from.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dict: status message and content.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# Main URL
|
|
44
|
+
request_url = f'{self.main_url}/groups/{workspace_id}/reports'
|
|
45
|
+
|
|
46
|
+
# If workspace ID was not informed, return error message...
|
|
47
|
+
if workspace_id == '':
|
|
48
|
+
return {'message': 'Missing workspace id, please check.', 'content': ''}
|
|
49
|
+
|
|
50
|
+
# If workspace ID was informed...
|
|
51
|
+
else:
|
|
52
|
+
filename = f'reports_{workspace_id}.xlsx'
|
|
53
|
+
|
|
54
|
+
# Make the request
|
|
55
|
+
r = requests.get(url=request_url, headers=self.headers)
|
|
56
|
+
|
|
57
|
+
# Get HTTP status and content
|
|
58
|
+
status = r.status_code
|
|
59
|
+
response = json.loads(r.content).get('value', '')
|
|
60
|
+
|
|
61
|
+
# If success...
|
|
62
|
+
if status == 200:
|
|
63
|
+
# Save to Excel file
|
|
64
|
+
df = pd.DataFrame(response)
|
|
65
|
+
df.to_excel(f'{self.data_dir}/{filename}', index=False)
|
|
66
|
+
|
|
67
|
+
return {'message': 'Success', 'content': response}
|
|
68
|
+
|
|
69
|
+
else:
|
|
70
|
+
# If any error happens, return message.
|
|
71
|
+
response = json.loads(r.content)
|
|
72
|
+
error_message = response['error']['message']
|
|
73
|
+
|
|
74
|
+
return {'message': {'error': error_message, 'content': response}}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_report_metadata(
|
|
78
|
+
self,
|
|
79
|
+
workspace_id: str = '',
|
|
80
|
+
report_id: str = '') -> Dict:
|
|
81
|
+
"""
|
|
82
|
+
Get report metadata for a specific report_id and workspace_id that the user has access to.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
workspace_id (str, optional): workspace id where the report is.
|
|
86
|
+
report_id (str, optional): report id to search pages from.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Dict: status message and content.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
# Main URL
|
|
93
|
+
request_url = f'{self.main_url}/groups/{workspace_id}/reports/{report_id}'
|
|
94
|
+
|
|
95
|
+
# If workspace ID was not informed, return error message...
|
|
96
|
+
if workspace_id == '':
|
|
97
|
+
return {'message': 'Missing workspace id, please check.', 'content': ''}
|
|
98
|
+
|
|
99
|
+
# If workspace ID was informed...
|
|
100
|
+
else:
|
|
101
|
+
filename = f'report_{report_id}.xlsx'
|
|
102
|
+
filepath = f'{self.data_dir}/pages/{filename}'
|
|
103
|
+
os.makedirs(filepath, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
# Make the request
|
|
106
|
+
r = requests.get(url=request_url, headers=self.headers)
|
|
107
|
+
|
|
108
|
+
# Get HTTP status and content
|
|
109
|
+
status = r.status_code
|
|
110
|
+
response = json.loads(r.content)
|
|
111
|
+
|
|
112
|
+
# If success...
|
|
113
|
+
if status == 200:
|
|
114
|
+
# # Save to Excel file
|
|
115
|
+
# df = pd.DataFrame(response)
|
|
116
|
+
# try:
|
|
117
|
+
# df.to_excel(filepath, index=False)
|
|
118
|
+
# except PermissionError as error:
|
|
119
|
+
# print('File is open already, cannot save it. Skipping...')
|
|
120
|
+
|
|
121
|
+
return {'message': 'Success', 'content': response}
|
|
122
|
+
|
|
123
|
+
else:
|
|
124
|
+
# If any error happens, return message.
|
|
125
|
+
response = json.loads(r.content)
|
|
126
|
+
error_message = response['error']['message']
|
|
127
|
+
|
|
128
|
+
return {'message': {'error': error_message, 'content': response}}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_report_name(self, workspace_id: str, report_id: str) -> str:
|
|
132
|
+
"""
|
|
133
|
+
Get Power BI report name.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
workspace_id (str): report workspace ID.
|
|
137
|
+
report_id (str): report ID.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
report_name: Power BI report name.
|
|
141
|
+
"""
|
|
142
|
+
report_name = self.get_report_metadata(workspace_id, report_id).get('content').get('name')
|
|
143
|
+
|
|
144
|
+
return report_name
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def list_report_pages(
|
|
148
|
+
self,
|
|
149
|
+
workspace_id: str = '',
|
|
150
|
+
report_id: str = '') -> Dict:
|
|
151
|
+
"""
|
|
152
|
+
List all report pages on a specific report_id and workspace_id that the user has access to.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
workspace_id (str, optional): workspace id where the report is.
|
|
156
|
+
report_id (str, optional): report id to search pages from.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Dict: status message and content.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
# Main URL
|
|
163
|
+
request_url = f'{self.main_url}/groups/{workspace_id}/reports/{report_id}/pages'
|
|
164
|
+
|
|
165
|
+
# If workspace ID was not informed, return error message...
|
|
166
|
+
if workspace_id == '':
|
|
167
|
+
return {'message': 'Missing workspace id, please check.', 'content': ''}
|
|
168
|
+
|
|
169
|
+
# If workspace ID was informed...
|
|
170
|
+
else:
|
|
171
|
+
filename = f'report_pages_{report_id}.xlsx'
|
|
172
|
+
filepath = f'{self.data_dir}/pages/{filename}'
|
|
173
|
+
os.makedirs(filepath, exist_ok=True)
|
|
174
|
+
|
|
175
|
+
# Make the request
|
|
176
|
+
r = requests.get(url=request_url, headers=self.headers)
|
|
177
|
+
|
|
178
|
+
# Get HTTP status and content
|
|
179
|
+
status = r.status_code
|
|
180
|
+
response = json.loads(r.content).get('value', '')
|
|
181
|
+
|
|
182
|
+
# If success...
|
|
183
|
+
if status == 200:
|
|
184
|
+
# Save to Excel file
|
|
185
|
+
df = pd.DataFrame(response)
|
|
186
|
+
try:
|
|
187
|
+
df.to_excel(filepath, index=False)
|
|
188
|
+
except PermissionError as error:
|
|
189
|
+
print('File is open already, cannot save it. Skipping...')
|
|
190
|
+
|
|
191
|
+
return {'message': 'Success', 'content': response}
|
|
192
|
+
|
|
193
|
+
else:
|
|
194
|
+
# If any error happens, return message.
|
|
195
|
+
response = json.loads(r.content)
|
|
196
|
+
error_message = response['error']['message']
|
|
197
|
+
|
|
198
|
+
return {'message': {'error': error_message, 'content': response}}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_report_json_pages_and_visuals(
|
|
202
|
+
self,
|
|
203
|
+
json_data: str,
|
|
204
|
+
workspace_id: str,
|
|
205
|
+
report_id: str) -> pd.DataFrame:
|
|
206
|
+
"""
|
|
207
|
+
Parses a Power BI report JSON to extract pages and visual details,
|
|
208
|
+
and returns the result as a Pandas DataFrame.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
json_data (dict): Power BI report JSON (legacy).
|
|
212
|
+
report_id (str): report id.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Dict: status message and content.
|
|
216
|
+
"""
|
|
217
|
+
def get_nested_value(data: dict, path: List[Any], default: Any = None) -> Any:
|
|
218
|
+
"""Safely traverses a nested dictionary/list structure."""
|
|
219
|
+
current = data
|
|
220
|
+
for key in path:
|
|
221
|
+
if isinstance(current, dict):
|
|
222
|
+
current = current.get(key)
|
|
223
|
+
elif isinstance(current, list) and isinstance(key, int) and len(current) > key:
|
|
224
|
+
current = current[key]
|
|
225
|
+
else:
|
|
226
|
+
return default
|
|
227
|
+
|
|
228
|
+
if current is None:
|
|
229
|
+
return default
|
|
230
|
+
return current
|
|
231
|
+
|
|
232
|
+
# List to hold flat dictionary records for the final DataFrame
|
|
233
|
+
report_records: List[Dict[str, Any]] = []
|
|
234
|
+
|
|
235
|
+
if isinstance(json_data, dict):
|
|
236
|
+
data = json_data
|
|
237
|
+
else:
|
|
238
|
+
try:
|
|
239
|
+
data = json.loads(json_data)
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
print("Error: Invalid JSON format.")
|
|
242
|
+
return pd.DataFrame()
|
|
243
|
+
|
|
244
|
+
sections = get_nested_value(data, ['config', 'sections'])
|
|
245
|
+
if not sections:
|
|
246
|
+
sections = data.get('sections', [])
|
|
247
|
+
|
|
248
|
+
for i, section in enumerate(sections):
|
|
249
|
+
page_index = i+1
|
|
250
|
+
page_name = section.get('displayName', 'Untitled Page')
|
|
251
|
+
visual_containers = section.get('visualContainers', [])
|
|
252
|
+
|
|
253
|
+
for vc in visual_containers:
|
|
254
|
+
# The 'name' property inside 'config' is the unique Visual ID
|
|
255
|
+
visual_id = get_nested_value(vc, ['config', 'name'], 'No ID')
|
|
256
|
+
visual_type = 'Unknown'
|
|
257
|
+
visual_title = 'No Title'
|
|
258
|
+
vc_config = vc.get('config', {})
|
|
259
|
+
|
|
260
|
+
# --- 1. Handle Visual Groups (e.g., Filter Pane) ---
|
|
261
|
+
single_visual_group = vc_config.get('singleVisualGroup')
|
|
262
|
+
if single_visual_group:
|
|
263
|
+
visual_type = 'Visual Group (Container)'
|
|
264
|
+
visual_title = single_visual_group.get('displayName', 'Visual Group')
|
|
265
|
+
|
|
266
|
+
else:
|
|
267
|
+
# --- 2. Handle Single Visuals (Charts, Tables, etc.) ---
|
|
268
|
+
single_visual = vc_config.get('singleVisual', {})
|
|
269
|
+
if single_visual:
|
|
270
|
+
visual_type = single_visual.get('visualType', 'Generic Visual')
|
|
271
|
+
objects = single_visual.get('objects', {})
|
|
272
|
+
vc_objects = single_visual.get('vcObjects', {})
|
|
273
|
+
|
|
274
|
+
# Define all known paths for static literal title extraction
|
|
275
|
+
title_paths = [
|
|
276
|
+
# Path 1: Most common path for user-set title (your suggested path)
|
|
277
|
+
['title', 0, 'properties', 'text', 'expr', 'Literal', 'Value'],
|
|
278
|
+
# Path 2: General Title (e.g., Navigators, some cards/KPIs)
|
|
279
|
+
['general', 0, 'properties', 'title', 'expr', 'Literal', 'Value'],
|
|
280
|
+
# Path 3: Text Visual/Button Label (often the first text object)
|
|
281
|
+
['text', 0, 'properties', 'text', 'expr', 'Literal', 'Value'],
|
|
282
|
+
# Path 4: Text Visual/Button Label (sometimes the second text object)
|
|
283
|
+
['text', 1, 'properties', 'text', 'expr', 'Literal', 'Value'],
|
|
284
|
+
# Path 5: Text Visual/Button Label (sometimes the second text object)
|
|
285
|
+
['text', 1, 'properties', 'text', 'expr', 'Literal', 'Value']
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
# Check on Objects
|
|
289
|
+
found_title = 'No Title'
|
|
290
|
+
for path in title_paths:
|
|
291
|
+
title_value = get_nested_value(objects, path)
|
|
292
|
+
|
|
293
|
+
if isinstance(title_value, str) and title_value:
|
|
294
|
+
found_title = title_value.strip("'").replace('\'', "'")
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
# Check on vcObjects
|
|
298
|
+
for path in title_paths:
|
|
299
|
+
title_value = get_nested_value(vc_objects, path)
|
|
300
|
+
|
|
301
|
+
if isinstance(title_value, str) and title_value:
|
|
302
|
+
found_title = title_value.strip("'").replace('\'', "'")
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
visual_title = found_title if found_title != 'No Title' else visual_type
|
|
306
|
+
|
|
307
|
+
# --- Diagnostic: Check for dynamic title expressions if literal is missing ---
|
|
308
|
+
title_expression = None
|
|
309
|
+
if visual_title == visual_type:
|
|
310
|
+
# Check for dynamic title expression
|
|
311
|
+
title_obj = get_nested_value(objects, ['title', 0, 'properties', 'text', 'expr'])
|
|
312
|
+
if title_obj and not get_nested_value(title_obj, ['Literal', 'Value']):
|
|
313
|
+
# Capture the full expression structure (DAX)
|
|
314
|
+
title_expression = str(title_obj)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# Append the structured record
|
|
318
|
+
record = {
|
|
319
|
+
'report_id': report_id,
|
|
320
|
+
'pageIndex': page_index,
|
|
321
|
+
'pageName': page_name,
|
|
322
|
+
'visual_id': visual_id,
|
|
323
|
+
'type': visual_type,
|
|
324
|
+
'title': visual_title,
|
|
325
|
+
'title_expression': title_expression # Contains DAX if title is dynamic
|
|
326
|
+
}
|
|
327
|
+
report_records.append(record)
|
|
328
|
+
|
|
329
|
+
# Convert the list of records into a Pandas DataFrame
|
|
330
|
+
report_name = self.get_report_name(workspace_id, report_id).replace(' ', '').replace('(', '').replace(')', '').strip()
|
|
331
|
+
df = pd.DataFrame(report_records)
|
|
332
|
+
df.sort_values(by=['pageIndex', 'title'], inplace=True)
|
|
333
|
+
df.to_excel(f'{self.data_dir}/pages_and_visuals/{report_name}.xlsx', index=False)
|
|
334
|
+
|
|
335
|
+
return df
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_legacy_report_json(
|
|
339
|
+
self,
|
|
340
|
+
workspace_id: str = '',
|
|
341
|
+
report_id: str = '',
|
|
342
|
+
operations: Operations = None) -> Dict:
|
|
343
|
+
"""
|
|
344
|
+
Get a specific report_id definition.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
workspace_id (str): workspace id where the report is.
|
|
348
|
+
report_id (str): report id to search pages from.
|
|
349
|
+
Operations (Operations): Operations class.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Dict: status message and content.
|
|
353
|
+
"""
|
|
354
|
+
def _decode_nested_json(value):
|
|
355
|
+
"""
|
|
356
|
+
Recursively decodes nested JSON strings inside dicts and lists.
|
|
357
|
+
"""
|
|
358
|
+
if isinstance(value, str):
|
|
359
|
+
try:
|
|
360
|
+
parsed = json.loads(value)
|
|
361
|
+
return _decode_nested_json(parsed)
|
|
362
|
+
except json.JSONDecodeError:
|
|
363
|
+
return value
|
|
364
|
+
elif isinstance(value, dict):
|
|
365
|
+
return {k: _decode_nested_json(v) for k, v in value.items()}
|
|
366
|
+
elif isinstance(value, list):
|
|
367
|
+
return [_decode_nested_json(v) for v in value]
|
|
368
|
+
return value
|
|
369
|
+
|
|
370
|
+
def _decode_base64_json_to_file(encoded_str: str, workspace_id: str, report_id: str) -> None:
|
|
371
|
+
"""
|
|
372
|
+
Decodes a Base64-encoded JSON string, fixes escaped JSON fields,
|
|
373
|
+
and dumps the result into a formatted JSON file.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
encoded_str (str): The Base64-encoded JSON string.
|
|
377
|
+
output_path (str): Path where the final JSON file will be saved.
|
|
378
|
+
"""
|
|
379
|
+
decoded_bytes = base64.b64decode(encoded_str)
|
|
380
|
+
decoded_str = decoded_bytes.decode('utf-8')
|
|
381
|
+
raw_data = json.loads(decoded_str)
|
|
382
|
+
cleaned_data = _decode_nested_json(raw_data)
|
|
383
|
+
|
|
384
|
+
report_name = self.get_report_name(workspace_id, report_id).replace(' ', '').replace('(', '').replace(')', '').strip()
|
|
385
|
+
output_path = f'{self.data_dir}/definitions/{report_name}.json'
|
|
386
|
+
|
|
387
|
+
with open(output_path, 'w', encoding='utf-8') as file:
|
|
388
|
+
json.dump(cleaned_data, file, indent=4, ensure_ascii=False)
|
|
389
|
+
|
|
390
|
+
return cleaned_data
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# Main URL
|
|
394
|
+
request_url = f'{self.main_fabric_url}/workspaces/{workspace_id}/reports/{report_id}/getDefinition'
|
|
395
|
+
|
|
396
|
+
# If workspace ID or report ID was not informed, return error message...
|
|
397
|
+
if workspace_id == '':
|
|
398
|
+
return {'message': 'Missing workspace id, please check.', 'content': ''}
|
|
399
|
+
if report_id == '':
|
|
400
|
+
return {'message': 'Missing report id, please check.', 'content': ''}
|
|
401
|
+
|
|
402
|
+
# Continue...
|
|
403
|
+
else:
|
|
404
|
+
|
|
405
|
+
# Make the request
|
|
406
|
+
r = requests.post(url=request_url, headers=self.headers)
|
|
407
|
+
|
|
408
|
+
# Get HTTP status and content
|
|
409
|
+
status = r.status_code
|
|
410
|
+
response = json.loads(r.content)
|
|
411
|
+
print(f'status_code={r.status_code}')
|
|
412
|
+
|
|
413
|
+
# If success...
|
|
414
|
+
if status == 202:
|
|
415
|
+
operation_id = r.headers.get('x-ms-operation-id')
|
|
416
|
+
print(f'operation_id={operation_id}')
|
|
417
|
+
|
|
418
|
+
while True:
|
|
419
|
+
active_operation_state = operations.get_operation_state(operation_id)
|
|
420
|
+
print('Operation state:', active_operation_state)
|
|
421
|
+
if active_operation_state:
|
|
422
|
+
operation_state = active_operation_state.get('operation_state', '')
|
|
423
|
+
if operation_state in ('Succeeded', 'Failed'):
|
|
424
|
+
sleep(1)
|
|
425
|
+
break
|
|
426
|
+
|
|
427
|
+
sleep(1)
|
|
428
|
+
|
|
429
|
+
print('Getting operation result. This might take several minutes...')
|
|
430
|
+
report_content = operations.get_operation_result(operation_id).get('content', '')
|
|
431
|
+
|
|
432
|
+
print('Parsing report content...')
|
|
433
|
+
report_definition = report_content.get('definition')
|
|
434
|
+
report_format = report_definition.get('format')
|
|
435
|
+
report_parts = report_definition.get('parts')
|
|
436
|
+
|
|
437
|
+
if report_format.lower() == 'pbir-legacy':
|
|
438
|
+
for part in report_parts:
|
|
439
|
+
if part.get('path') == 'report.json':
|
|
440
|
+
report_json_byte_string = part.get('payload')
|
|
441
|
+
break
|
|
442
|
+
|
|
443
|
+
report_json = _decode_base64_json_to_file(report_json_byte_string, workspace_id, report_id)
|
|
444
|
+
|
|
445
|
+
return {'message': 'Success', 'content': report_json}
|
|
446
|
+
|
|
447
|
+
# Report not on Legacy format.
|
|
448
|
+
else:
|
|
449
|
+
return {'message': {'error': 'Report format invalid, only PBIR-Legacy is supported.', 'format': report_format}, 'content': report_definition}
|
|
450
|
+
else:
|
|
451
|
+
# If any error happens, return message.
|
|
452
|
+
error_message = response['error']['message']
|
|
453
|
+
|
|
454
|
+
return {'message': {'error': error_message}, 'content': response}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def export_report(
|
|
458
|
+
self,
|
|
459
|
+
workspace_id: str = '',
|
|
460
|
+
workspace_name: str = '',
|
|
461
|
+
report_id: str = '',
|
|
462
|
+
report_name: str = '',
|
|
463
|
+
dataset_name: str = '',
|
|
464
|
+
replace_existing: bool = False) -> Dict:
|
|
465
|
+
"""
|
|
466
|
+
Export a specific report to a .pbix file.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
workspace_id (str, optional): workspace id to search datasets from.
|
|
470
|
+
workspace_name (str, optional): workspace name to be associated with the report.
|
|
471
|
+
report_id (str, optional): report id to be exported.
|
|
472
|
+
report_name (str, optional): report name to be saved.
|
|
473
|
+
dataset_name (str, optional): dataset name to be associated with the report.
|
|
474
|
+
replace_existing (bool, optional): if True, replace existing file with the same name.
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
Dict: status message and content.
|
|
478
|
+
"""
|
|
479
|
+
filename = f'{report_name}.pbix'
|
|
480
|
+
file_path = f'{self.data_dir}/exports/{dataset_name}/{workspace_name}'
|
|
481
|
+
|
|
482
|
+
file_exists = os.path.exists(f'{file_path}/{filename}')
|
|
483
|
+
|
|
484
|
+
if (not replace_existing) and (file_exists):
|
|
485
|
+
return {'message': f'File {filename} already exists.', 'content': ''}
|
|
486
|
+
|
|
487
|
+
print(f'Exporting {report_name}')
|
|
488
|
+
|
|
489
|
+
# Main URL
|
|
490
|
+
request_url = f'{self.main_url}/groups/{workspace_id}/reports/{report_id}/Export/?DownloadType=LiveConnect'
|
|
491
|
+
|
|
492
|
+
# If workspace ID was not informed, return error message...
|
|
493
|
+
if workspace_id == '':
|
|
494
|
+
return {'message': 'Missing workspace id, please check.', 'content': ''}
|
|
495
|
+
if report_id == '':
|
|
496
|
+
return {'message': 'Missing report id, please check.', 'content': ''}
|
|
497
|
+
|
|
498
|
+
# If workspace ID and report ID were informed...
|
|
499
|
+
else:
|
|
500
|
+
|
|
501
|
+
create_directory(file_path)
|
|
502
|
+
|
|
503
|
+
# Make the request
|
|
504
|
+
r = requests.get(url=request_url, headers=self.headers)
|
|
505
|
+
|
|
506
|
+
# Get HTTP status and content
|
|
507
|
+
status = r.status_code
|
|
508
|
+
|
|
509
|
+
# If success...
|
|
510
|
+
if status == 200:
|
|
511
|
+
# Save to PBIX file
|
|
512
|
+
with open(f'{file_path}/{filename}', 'wb') as f:
|
|
513
|
+
f.write(r.content)
|
|
514
|
+
|
|
515
|
+
return {'message': 'Success', 'content': 'File downloaded successfully.'}
|
|
516
|
+
|
|
517
|
+
else:
|
|
518
|
+
print(f'Error exporting {report_name}:\n{r.content}')
|
|
519
|
+
|
|
520
|
+
return {'message': {'error': f'Error with status code {status}'}, 'content': ''}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def get_report_measures(
|
|
524
|
+
self,
|
|
525
|
+
workspace_id: str = '',
|
|
526
|
+
report_id: str = '',
|
|
527
|
+
operations: Operations = None) -> Dict:
|
|
528
|
+
"""
|
|
529
|
+
Extract report-level measures from a report via the Fabric API
|
|
530
|
+
and generate a DAX Query View script (.txt).
|
|
531
|
+
|
|
532
|
+
Supports both PBIR and PBIR-Legacy formats. For PBIR, parses the
|
|
533
|
+
reportExtensions.json part. For PBIR-Legacy, decodes report.json
|
|
534
|
+
and extracts measures from config.modelExtensions.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
workspace_id (str): workspace id where the report is.
|
|
538
|
+
report_id (str): report id.
|
|
539
|
+
operations (Operations): Operations class instance.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
Dict: status message and content with keys:
|
|
543
|
+
- measures (list): extracted measure definitions.
|
|
544
|
+
- model_measures (list): referenced semantic-model measures.
|
|
545
|
+
- dax_script (str): generated DAX Query View script.
|
|
546
|
+
- dax_script_path (str): path where the .txt was saved.
|
|
547
|
+
- measures_json_path (str): path where the .json was saved.
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
# Main URL
|
|
551
|
+
request_url = f'{self.main_fabric_url}/workspaces/{workspace_id}/reports/{report_id}/getDefinition'
|
|
552
|
+
|
|
553
|
+
# If workspace ID or report ID was not informed, return error message...
|
|
554
|
+
if workspace_id == '':
|
|
555
|
+
return {'message': 'Missing workspace id, please check.', 'content': ''}
|
|
556
|
+
if report_id == '':
|
|
557
|
+
return {'message': 'Missing report id, please check.', 'content': ''}
|
|
558
|
+
|
|
559
|
+
# Make the request
|
|
560
|
+
r = requests.post(url=request_url, headers=self.headers)
|
|
561
|
+
|
|
562
|
+
# Get HTTP status and content
|
|
563
|
+
status = r.status_code
|
|
564
|
+
response = json.loads(r.content)
|
|
565
|
+
print(f'status_code={status}')
|
|
566
|
+
|
|
567
|
+
if status != 202:
|
|
568
|
+
error_message = response.get('error', {}).get('message', 'Unknown error')
|
|
569
|
+
return {'message': {'error': error_message}, 'content': response}
|
|
570
|
+
|
|
571
|
+
# Poll for operation completion
|
|
572
|
+
operation_id = r.headers.get('x-ms-operation-id')
|
|
573
|
+
print(f'operation_id={operation_id}')
|
|
574
|
+
|
|
575
|
+
while True:
|
|
576
|
+
active_operation_state = operations.get_operation_state(operation_id)
|
|
577
|
+
print('Operation state:', active_operation_state)
|
|
578
|
+
if active_operation_state:
|
|
579
|
+
operation_state = active_operation_state.get('operation_state', '')
|
|
580
|
+
if operation_state in ('Succeeded', 'Failed'):
|
|
581
|
+
sleep(1)
|
|
582
|
+
break
|
|
583
|
+
|
|
584
|
+
sleep(1)
|
|
585
|
+
|
|
586
|
+
# Get operation result
|
|
587
|
+
print('Getting operation result...')
|
|
588
|
+
report_content = operations.get_operation_result(operation_id).get('content', '')
|
|
589
|
+
|
|
590
|
+
print('Parsing report content...')
|
|
591
|
+
report_definition = report_content.get('definition')
|
|
592
|
+
report_format = report_definition.get('format')
|
|
593
|
+
report_parts = report_definition.get('parts')
|
|
594
|
+
|
|
595
|
+
# Extract measures based on report format
|
|
596
|
+
print(f'Report format: {report_format}')
|
|
597
|
+
|
|
598
|
+
if report_format.lower() == 'pbir':
|
|
599
|
+
# PBIR: measures live in reportExtensions.json
|
|
600
|
+
extensions_payload = None
|
|
601
|
+
for part in report_parts:
|
|
602
|
+
if part.get('path') == 'definition/reportExtensions.json':
|
|
603
|
+
extensions_payload = part.get('payload')
|
|
604
|
+
break
|
|
605
|
+
|
|
606
|
+
if extensions_payload is None:
|
|
607
|
+
return {
|
|
608
|
+
'message': 'No reportExtensions.json found. This report has no report-level measures.',
|
|
609
|
+
'content': ''
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
decoded_bytes = base64.b64decode(extensions_payload)
|
|
613
|
+
decoded_str = decoded_bytes.decode('utf-8')
|
|
614
|
+
extensions_data = json.loads(decoded_str)
|
|
615
|
+
measures = self._parse_report_extensions(extensions_data)
|
|
616
|
+
|
|
617
|
+
elif report_format.lower() == 'pbir-legacy':
|
|
618
|
+
# PBIR-Legacy: measures live in report.json -> config.modelExtensions
|
|
619
|
+
report_payload = None
|
|
620
|
+
for part in report_parts:
|
|
621
|
+
if part.get('path') == 'report.json':
|
|
622
|
+
report_payload = part.get('payload')
|
|
623
|
+
break
|
|
624
|
+
|
|
625
|
+
if report_payload is None:
|
|
626
|
+
return {
|
|
627
|
+
'message': 'No report.json found in report definition.',
|
|
628
|
+
'content': ''
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
decoded_bytes = base64.b64decode(report_payload)
|
|
632
|
+
decoded_str = decoded_bytes.decode('utf-8')
|
|
633
|
+
report_json = json.loads(decoded_str)
|
|
634
|
+
|
|
635
|
+
config = report_json.get('config', {})
|
|
636
|
+
if isinstance(config, str):
|
|
637
|
+
config = json.loads(config)
|
|
638
|
+
model_extensions = config.get('modelExtensions', [])
|
|
639
|
+
|
|
640
|
+
if not model_extensions:
|
|
641
|
+
return {
|
|
642
|
+
'message': 'No modelExtensions found. This report has no report-level measures.',
|
|
643
|
+
'content': ''
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
# modelExtensions has the same entities[].measures[] structure
|
|
647
|
+
extensions_data = {'entities': []}
|
|
648
|
+
for ext in model_extensions:
|
|
649
|
+
for entity in ext.get('entities', []):
|
|
650
|
+
extensions_data['entities'].append(entity)
|
|
651
|
+
|
|
652
|
+
measures = self._parse_report_extensions(extensions_data)
|
|
653
|
+
|
|
654
|
+
else:
|
|
655
|
+
return {
|
|
656
|
+
'message': {
|
|
657
|
+
'error': f'Unsupported report format: {report_format}',
|
|
658
|
+
'format': report_format
|
|
659
|
+
},
|
|
660
|
+
'content': ''
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if not measures:
|
|
664
|
+
return {'message': 'No report-level measures found.', 'content': ''}
|
|
665
|
+
|
|
666
|
+
# Identify model-level dependencies
|
|
667
|
+
model_measures = self._get_model_measure_references(measures)
|
|
668
|
+
|
|
669
|
+
# Generate scripts
|
|
670
|
+
dax_script = self._generate_dax_query_script(measures)
|
|
671
|
+
tmdl_script = self._generate_tmdl_script(measures)
|
|
672
|
+
|
|
673
|
+
# Save files
|
|
674
|
+
report_name = self.get_report_name(workspace_id, report_id).replace(' ', '').replace('(', '').replace(')', '').strip()
|
|
675
|
+
output_dir = f'{self.data_dir}/measures'
|
|
676
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
677
|
+
|
|
678
|
+
# Save DAX script (.txt)
|
|
679
|
+
dax_path = f'{output_dir}/{report_name}_measures.txt'
|
|
680
|
+
with open(dax_path, 'w', encoding='utf-8') as f:
|
|
681
|
+
f.write(dax_script)
|
|
682
|
+
|
|
683
|
+
# Save TMDL script (.tmdl)
|
|
684
|
+
tmdl_path = f'{output_dir}/{report_name}_measures.tmdl'
|
|
685
|
+
with open(tmdl_path, 'w', encoding='utf-8') as f:
|
|
686
|
+
f.write(tmdl_script)
|
|
687
|
+
|
|
688
|
+
# Save measures list (.json)
|
|
689
|
+
json_path = f'{output_dir}/{report_name}_measures.json'
|
|
690
|
+
measures_export = [
|
|
691
|
+
{k: v for k, v in m.items() if k != 'references'}
|
|
692
|
+
for m in measures
|
|
693
|
+
]
|
|
694
|
+
with open(json_path, 'w', encoding='utf-8') as f:
|
|
695
|
+
json.dump(measures_export, f, indent=2, ensure_ascii=False)
|
|
696
|
+
|
|
697
|
+
print(f'Measures extracted: {len(measures)}')
|
|
698
|
+
print(f'Model dependencies: {len(model_measures)}')
|
|
699
|
+
print(f'DAX script saved to: {dax_path}')
|
|
700
|
+
print(f'TMDL script saved to: {tmdl_path}')
|
|
701
|
+
print(f'Measures JSON saved to: {json_path}')
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
'message': 'Success',
|
|
705
|
+
'content': {
|
|
706
|
+
'measures': measures,
|
|
707
|
+
'model_measures': model_measures,
|
|
708
|
+
'dax_script': dax_script,
|
|
709
|
+
'dax_script_path': dax_path,
|
|
710
|
+
'tmdl_script': tmdl_script,
|
|
711
|
+
'tmdl_script_path': tmdl_path,
|
|
712
|
+
'measures_json_path': json_path
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _parse_report_extensions(self, data: dict) -> List[Dict[str, Any]]:
|
|
718
|
+
"""
|
|
719
|
+
Parse report extensions content and extract measure definitions.
|
|
720
|
+
Works with both PBIR (reportExtensions.json) and PBIR-Legacy
|
|
721
|
+
(config.modelExtensions) structures.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
data (dict): parsed extensions content with 'entities' key.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
List[Dict]: list of measure dicts with keys:
|
|
728
|
+
entity, name, dataType, expression, formatString,
|
|
729
|
+
displayFolder, description, references.
|
|
730
|
+
"""
|
|
731
|
+
measures = []
|
|
732
|
+
|
|
733
|
+
for entity in data.get('entities', []):
|
|
734
|
+
entity_name = entity.get('name', '')
|
|
735
|
+
|
|
736
|
+
for m in entity.get('measures', []):
|
|
737
|
+
expression = m.get('expression', '')
|
|
738
|
+
expression = expression.replace('\r\n', '\n').strip()
|
|
739
|
+
|
|
740
|
+
# formatString: PBIR uses top-level key,
|
|
741
|
+
# PBIR-Legacy nests it under formatInformation
|
|
742
|
+
format_string = m.get('formatString', '')
|
|
743
|
+
if not format_string:
|
|
744
|
+
format_info = m.get('formatInformation', {})
|
|
745
|
+
if format_info:
|
|
746
|
+
format_string = format_info.get('formatString', '')
|
|
747
|
+
|
|
748
|
+
measures.append({
|
|
749
|
+
'entity': entity_name,
|
|
750
|
+
'name': m.get('name', ''),
|
|
751
|
+
'dataType': m.get('dataType', ''),
|
|
752
|
+
'expression': expression,
|
|
753
|
+
'formatString': format_string,
|
|
754
|
+
'displayFolder': m.get('displayFolder', ''),
|
|
755
|
+
'description': m.get('description', ''),
|
|
756
|
+
'references': m.get('references', {}),
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
return measures
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _get_model_measure_references(
|
|
763
|
+
self,
|
|
764
|
+
measures: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
|
765
|
+
"""
|
|
766
|
+
Collect unique semantic-model measure references (not report-level).
|
|
767
|
+
|
|
768
|
+
Model measures are those referenced WITHOUT "schema": "extension",
|
|
769
|
+
meaning they live in the connected semantic model.
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
measures (list): parsed measure list from _parse_report_extensions.
|
|
773
|
+
|
|
774
|
+
Returns:
|
|
775
|
+
List[Dict]: sorted list of {'entity': ..., 'name': ...} dicts.
|
|
776
|
+
"""
|
|
777
|
+
seen = set()
|
|
778
|
+
model_measures = []
|
|
779
|
+
|
|
780
|
+
for m in measures:
|
|
781
|
+
for ref in m.get('references', {}).get('measures', []):
|
|
782
|
+
if ref.get('schema') != 'extension':
|
|
783
|
+
entity = ref.get('entity', 'Calculations')
|
|
784
|
+
name = ref.get('name', '')
|
|
785
|
+
key = (entity, name)
|
|
786
|
+
if key not in seen:
|
|
787
|
+
seen.add(key)
|
|
788
|
+
model_measures.append({'entity': entity, 'name': name})
|
|
789
|
+
|
|
790
|
+
model_measures.sort(key=lambda x: x['name'])
|
|
791
|
+
return model_measures
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _generate_dax_query_script(self, measures: List[Dict[str, Any]]) -> str:
|
|
795
|
+
"""
|
|
796
|
+
Generate a DAX Query View script from report-level measures.
|
|
797
|
+
|
|
798
|
+
The output follows the standard DAX Query View / DAX Studio format:
|
|
799
|
+
- Commented model measure references at the top.
|
|
800
|
+
- DEFINE block with all MEASURE definitions.
|
|
801
|
+
- EVALUATE SUMMARIZECOLUMNS(...) block to validate.
|
|
802
|
+
|
|
803
|
+
Args:
|
|
804
|
+
measures (list): parsed measure list from _parse_report_extensions.
|
|
805
|
+
|
|
806
|
+
Returns:
|
|
807
|
+
str: complete DAX query script.
|
|
808
|
+
"""
|
|
809
|
+
model_measures = self._get_model_measure_references(measures)
|
|
810
|
+
|
|
811
|
+
lines = []
|
|
812
|
+
|
|
813
|
+
# -- Commented model measure references -- #
|
|
814
|
+
for mm in model_measures:
|
|
815
|
+
lines.append(f"// MEASURE '{mm['entity']}'[{mm['name']}]")
|
|
816
|
+
|
|
817
|
+
# -- DEFINE block -- #
|
|
818
|
+
lines.append('DEFINE')
|
|
819
|
+
|
|
820
|
+
for m in measures:
|
|
821
|
+
entity = m['entity']
|
|
822
|
+
name = m['name']
|
|
823
|
+
expr_lines = m['expression'].split('\n')
|
|
824
|
+
first_line = expr_lines[0] if expr_lines else ''
|
|
825
|
+
|
|
826
|
+
lines.append(f" MEASURE '{entity}'[{name}] = {first_line}")
|
|
827
|
+
|
|
828
|
+
for eline in expr_lines[1:]:
|
|
829
|
+
lines.append(eline)
|
|
830
|
+
|
|
831
|
+
# -- EVALUATE block -- #
|
|
832
|
+
lines.append('')
|
|
833
|
+
lines.append('EVALUATE')
|
|
834
|
+
lines.append(' SUMMARIZECOLUMNS(')
|
|
835
|
+
|
|
836
|
+
eval_items = []
|
|
837
|
+
for mm in model_measures:
|
|
838
|
+
eval_items.append(f' "{mm["name"]}", [{mm["name"]}]')
|
|
839
|
+
for m in measures:
|
|
840
|
+
eval_items.append(f' "{m["name"]}", [{m["name"]}]')
|
|
841
|
+
|
|
842
|
+
for i, item in enumerate(eval_items):
|
|
843
|
+
if i < len(eval_items) - 1:
|
|
844
|
+
lines.append(item + ',')
|
|
845
|
+
else:
|
|
846
|
+
lines.append(item)
|
|
847
|
+
|
|
848
|
+
lines.append(' )')
|
|
849
|
+
|
|
850
|
+
return '\n'.join(lines) + '\n'
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _generate_tmdl_script(self, measures: List[Dict[str, Any]]) -> str:
|
|
854
|
+
"""
|
|
855
|
+
Generate a TMDL (Tabular Model Definition Language) script from
|
|
856
|
+
report-level measures using the createOrReplace command.
|
|
857
|
+
|
|
858
|
+
Measures are grouped by entity (table). The output follows the
|
|
859
|
+
TMDL script format used by Analysis Services / Fabric semantic models.
|
|
860
|
+
|
|
861
|
+
Args:
|
|
862
|
+
measures (list): parsed measure list from _parse_report_extensions.
|
|
863
|
+
|
|
864
|
+
Returns:
|
|
865
|
+
str: complete TMDL script.
|
|
866
|
+
"""
|
|
867
|
+
# Group measures by entity
|
|
868
|
+
entities = {}
|
|
869
|
+
for m in measures:
|
|
870
|
+
entity = m['entity']
|
|
871
|
+
if entity not in entities:
|
|
872
|
+
entities[entity] = []
|
|
873
|
+
entities[entity].append(m)
|
|
874
|
+
|
|
875
|
+
lines = ['createOrReplace']
|
|
876
|
+
|
|
877
|
+
for entity_name, entity_measures in entities.items():
|
|
878
|
+
lines.append('')
|
|
879
|
+
lines.append(f'\tref table {entity_name}')
|
|
880
|
+
|
|
881
|
+
for m in entity_measures:
|
|
882
|
+
lines.append('')
|
|
883
|
+
|
|
884
|
+
# Description as TMDL doc comment
|
|
885
|
+
description = m.get('description', '')
|
|
886
|
+
if description:
|
|
887
|
+
lines.append(f'\t\t/// {description}')
|
|
888
|
+
|
|
889
|
+
name = m['name']
|
|
890
|
+
expression = m['expression']
|
|
891
|
+
expr_lines = expression.split('\n')
|
|
892
|
+
|
|
893
|
+
# Single-line vs multi-line expression
|
|
894
|
+
if len(expr_lines) == 1:
|
|
895
|
+
lines.append(f"\t\tmeasure '{name}' = {expression}")
|
|
896
|
+
else:
|
|
897
|
+
lines.append(f"\t\tmeasure '{name}' =")
|
|
898
|
+
for eline in expr_lines:
|
|
899
|
+
lines.append(f'\t\t\t\t{eline}')
|
|
900
|
+
|
|
901
|
+
# Properties
|
|
902
|
+
format_string = m.get('formatString', '')
|
|
903
|
+
if format_string:
|
|
904
|
+
lines.append(f'\t\t\tformatString: {format_string}')
|
|
905
|
+
|
|
906
|
+
display_folder = m.get('displayFolder', '')
|
|
907
|
+
if display_folder:
|
|
908
|
+
lines.append(f'\t\t\tdisplayFolder: {display_folder}')
|
|
909
|
+
|
|
910
|
+
return '\n'.join(lines) + '\n'
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def rebind_report(
|
|
914
|
+
self,
|
|
915
|
+
workspace_id: str,
|
|
916
|
+
report_id: str,
|
|
917
|
+
new_dataset_id: str,
|
|
918
|
+
new_dataset_workspace_id: str,
|
|
919
|
+
admin: 'admin_module.Admin',
|
|
920
|
+
dataset: 'dataset_module.Dataset') -> Dict:
|
|
921
|
+
"""
|
|
922
|
+
Rebinds a report to a new dataset/semantic model and migrates Read access.
|
|
923
|
+
|
|
924
|
+
Users with Read access to the report are automatically granted Read access
|
|
925
|
+
to the original dataset. Since rebinding does not carry that access over,
|
|
926
|
+
this method fetches those users via the Admin API and explicitly grants
|
|
927
|
+
them Read access on the new dataset after rebinding.
|
|
928
|
+
|
|
929
|
+
Args:
|
|
930
|
+
workspace_id (str): workspace id where the report lives.
|
|
931
|
+
report_id (str): report id to rebind.
|
|
932
|
+
new_dataset_id (str): id of the new dataset/semantic model.
|
|
933
|
+
new_dataset_workspace_id (str): workspace id where the new dataset lives.
|
|
934
|
+
admin (Admin): Admin instance (requires read-only admin API permission).
|
|
935
|
+
dataset (Dataset): Dataset instance used to grant access on the new dataset.
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
Dict: status message and content with keys:
|
|
939
|
+
- rebind: result of the rebind operation.
|
|
940
|
+
- users_migrated: list of users granted access on the new dataset.
|
|
941
|
+
- users_failed: list of users where granting access failed.
|
|
942
|
+
"""
|
|
943
|
+
|
|
944
|
+
if not workspace_id:
|
|
945
|
+
return {'message': 'Missing workspace id, please check.', 'content': ''}
|
|
946
|
+
if not report_id:
|
|
947
|
+
return {'message': 'Missing report id, please check.', 'content': ''}
|
|
948
|
+
if not new_dataset_id:
|
|
949
|
+
return {'message': 'Missing new dataset id, please check.', 'content': ''}
|
|
950
|
+
if not new_dataset_workspace_id:
|
|
951
|
+
return {'message': 'Missing new dataset workspace id, please check.', 'content': ''}
|
|
952
|
+
|
|
953
|
+
# Step 1 — get report users before rebinding (requires admin permission)
|
|
954
|
+
users_result = admin.get_report_users_as_admin(report_id)
|
|
955
|
+
if 'error' in str(users_result.get('message', '')):
|
|
956
|
+
return {'message': users_result['message'], 'content': ''}
|
|
957
|
+
|
|
958
|
+
report_users = users_result.get('content', [])
|
|
959
|
+
|
|
960
|
+
# Keep only users with explicit Read access
|
|
961
|
+
read_users = [u for u in report_users if u.get('reportUserAccessRight') == 'Read']
|
|
962
|
+
|
|
963
|
+
# Step 2 — rebind the report
|
|
964
|
+
request_url = f'{self.main_url}/groups/{workspace_id}/reports/{report_id}/Rebind'
|
|
965
|
+
r = requests.post(
|
|
966
|
+
url=request_url,
|
|
967
|
+
headers=self.headers,
|
|
968
|
+
json={'datasetId': new_dataset_id}
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
status = r.status_code
|
|
972
|
+
|
|
973
|
+
if status != 200:
|
|
974
|
+
response = json.loads(r.content)
|
|
975
|
+
error_message = response.get('error', {}).get('message', 'Unknown error')
|
|
976
|
+
return {'message': {'error': error_message}, 'content': response}
|
|
977
|
+
|
|
978
|
+
# Step 3 — grant Read access on the new dataset to migrated users (concurrent)
|
|
979
|
+
users_migrated = []
|
|
980
|
+
users_failed = []
|
|
981
|
+
|
|
982
|
+
def _grant_access(user):
|
|
983
|
+
identifier = user.get('identifier', '')
|
|
984
|
+
principal_type = user.get('principalType', 'User')
|
|
985
|
+
result = dataset.add_user(
|
|
986
|
+
user_principal_name=identifier,
|
|
987
|
+
workspace_id=new_dataset_workspace_id,
|
|
988
|
+
dataset_id=new_dataset_id,
|
|
989
|
+
access_right='Read',
|
|
990
|
+
user_type=principal_type
|
|
991
|
+
)
|
|
992
|
+
return identifier, result
|
|
993
|
+
|
|
994
|
+
with ThreadPoolExecutor() as executor:
|
|
995
|
+
futures = {executor.submit(_grant_access, user): user for user in read_users}
|
|
996
|
+
for future in as_completed(futures):
|
|
997
|
+
identifier, result = future.result()
|
|
998
|
+
if result.get('message') == 'Success':
|
|
999
|
+
users_migrated.append(identifier)
|
|
1000
|
+
else:
|
|
1001
|
+
users_failed.append({'identifier': identifier, 'error': result.get('message')})
|
|
1002
|
+
|
|
1003
|
+
return {
|
|
1004
|
+
'message': 'Success',
|
|
1005
|
+
'content': {
|
|
1006
|
+
'rebind': f'Report {report_id} rebound to dataset {new_dataset_id}.',
|
|
1007
|
+
'users_migrated': users_migrated,
|
|
1008
|
+
'users_failed': users_failed
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
|