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.
@@ -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
+