mbu-dev-shared-components 2.4.2__tar.gz → 2.4.4__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.
Files changed (61) hide show
  1. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/PKG-INFO +5 -1
  2. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/msoffice365/sharepoint_api/files.py +273 -4
  3. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/base_ui.py +11 -4
  4. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/document.py +6 -3
  5. mbu_dev_shared_components-2.4.4/mbu_dev_shared_components/tests/go_tests/go_integration_tests.py +278 -0
  6. mbu_dev_shared_components-2.4.4/mbu_dev_shared_components/tests/go_tests/objects_tests.py +124 -0
  7. mbu_dev_shared_components-2.4.4/mbu_dev_shared_components/tests/msoffice_tests/msoffice_integration_tests.py +188 -0
  8. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components.egg-info/PKG-INFO +5 -1
  9. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components.egg-info/SOURCES.txt +3 -0
  10. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components.egg-info/requires.txt +5 -0
  11. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/pyproject.toml +8 -1
  12. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/LICENSE +0 -0
  13. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/README.md +0 -0
  14. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/database/__init__.py +0 -0
  15. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/database/constants.py +0 -0
  16. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/database/logging.py +0 -0
  17. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/database/utility.py +0 -0
  18. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/getorganized/__init__.py +0 -0
  19. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/getorganized/auth.py +0 -0
  20. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/getorganized/cases.py +0 -0
  21. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/getorganized/contacts.py +0 -0
  22. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/getorganized/documents.py +0 -0
  23. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/getorganized/objects.py +0 -0
  24. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/google/__init__.py +0 -0
  25. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/google/api/__init__.py +0 -0
  26. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/google/api/auth.py +0 -0
  27. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/google/workspace/__init__.py +0 -0
  28. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/google/workspace/alerts.py +0 -0
  29. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/msoffice365/__init__.py +0 -0
  30. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/msoffice365/excel/__init__.py +0 -0
  31. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/msoffice365/excel/excel_reader.py +0 -0
  32. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/msoffice365/sharepoint_api/__init__.py +0 -0
  33. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/os2forms/__init__.py +0 -0
  34. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/os2forms/documents.py +0 -0
  35. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/os2forms/forms.py +0 -0
  36. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/romexis/__init__.py +0 -0
  37. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/romexis/db_handler.py +0 -0
  38. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/romexis/helper_functions.py +0 -0
  39. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/sap/__init__.py +0 -0
  40. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/sap/create_invoice.py +0 -0
  41. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/__init__.py +0 -0
  42. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/__init__.py +0 -0
  43. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/app_handler.py +0 -0
  44. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/appointment.py +0 -0
  45. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/clinic.py +0 -0
  46. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/edi_portal.py +0 -0
  47. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/event.py +0 -0
  48. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/exceptions.py +0 -0
  49. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/handler_base.py +0 -0
  50. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/journal_note.py +0 -0
  51. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/application/patient.py +0 -0
  52. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/database/__init__.py +0 -0
  53. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/solteqtand/database/db_handler.py +0 -0
  54. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/utils/__init__.py +0 -0
  55. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/utils/db_stored_procedure_executor.py +0 -0
  56. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/utils/fernet_encryptor.py +0 -0
  57. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/utils/file_handler.py +0 -0
  58. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components/utils/json_handler.py +0 -0
  59. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components.egg-info/dependency_links.txt +0 -0
  60. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/mbu_dev_shared_components.egg-info/top_level.txt +0 -0
  61. {mbu_dev_shared_components-2.4.2 → mbu_dev_shared_components-2.4.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mbu_dev_shared_components
3
- Version: 2.4.2
3
+ Version: 2.4.4
4
4
  Summary: Shared components to use in RPA projects
5
5
  Author-email: MBU <rpa@mbu.aarhus.dk>
6
6
  License-Expression: MIT
@@ -19,7 +19,11 @@ Requires-Dist: uiautomation
19
19
  Requires-Dist: pillow
20
20
  Requires-Dist: psutil
21
21
  Requires-Dist: docx2pdf
22
+ Requires-Dist: pandas>=2.2.3
22
23
  Requires-Dist: rawpy
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-dependency>=0.5.1; extra == "dev"
23
27
  Dynamic: license-file
24
28
 
25
29
  # MBU Dev Shared Components
@@ -29,12 +29,25 @@ Example:
29
29
  sp.download_files("FolderName", "C:\\LocalPath")
30
30
  """
31
31
 
32
- from pathlib import PurePath
33
- from typing import Optional, List
34
32
  import os
33
+
34
+ import math
35
+
36
+ import traceback
37
+
38
+ from pathlib import PurePath
39
+
40
+ from io import BytesIO
41
+
42
+ from typing import Optional, List, Dict, Any
43
+
44
+ from openpyxl.styles import Font, Alignment
45
+ from openpyxl import load_workbook
46
+
47
+ import pandas as pd
48
+
35
49
  from office365.runtime.auth.user_credential import UserCredential
36
50
  from office365.sharepoint.client_context import ClientContext
37
-
38
51
  from office365.sharepoint.files.file import File
39
52
 
40
53
 
@@ -136,7 +149,6 @@ class Sharepoint:
136
149
  file_content = File.open_binary(self.ctx, file_url)
137
150
  return file_content.content
138
151
  except Exception:
139
- import traceback
140
152
  print("Failed to download file:")
141
153
  traceback.print_exc()
142
154
  return None
@@ -249,3 +261,260 @@ class Sharepoint:
249
261
  print(f"File '{file_name}' uploaded successfully to '{folder_url}'.")
250
262
  except Exception as e:
251
263
  print(f"Failed to upload file '{file_name}': {e}")
264
+
265
+ def append_row_to_sharepoint_excel(
266
+ self,
267
+ required_headers: Optional[List[str]] = None,
268
+ folder_name: str = "",
269
+ excel_file_name: str = "",
270
+ sheet_name: str = "",
271
+ new_row: Dict = None,
272
+ ) -> None:
273
+ """
274
+ • Appends a row to an existing Excel file.
275
+ • Sorts and formats based on provided parameters.
276
+ """
277
+
278
+ # 1. Pull file
279
+ binary_file = self.fetch_file_using_open_binary(excel_file_name, folder_name)
280
+
281
+ if binary_file is None:
282
+ raise FileNotFoundError(f"File '{excel_file_name}' not found in folder '{folder_name}'.")
283
+
284
+ wb = load_workbook(BytesIO(binary_file))
285
+
286
+ if sheet_name not in wb.sheetnames:
287
+ raise ValueError(f"Sheet '{sheet_name}' not found in '{excel_file_name}'")
288
+
289
+ ws = wb[sheet_name]
290
+
291
+ # 2. Validate headers
292
+ if required_headers:
293
+ current_headers = [cell.value for cell in ws[1]]
294
+
295
+ if current_headers != required_headers:
296
+ raise ValueError(
297
+ f"Header mismatch in sheet '{sheet_name}'!\n"
298
+ f"Expected: {required_headers}\n"
299
+ f"Found: {current_headers}"
300
+ )
301
+
302
+ # 2.5 Clean up empty rows before appending
303
+ for row_idx in range(ws.max_row, 1, -1): # Start from bottom, skip header
304
+ row_values = [cell.value for cell in ws[row_idx]]
305
+
306
+ if all(cell is None for cell in row_values):
307
+ ws.delete_rows(row_idx)
308
+
309
+ # 3. Append new row to sheet
310
+ ws.append([new_row.get(header.value, "") for header in ws[1]])
311
+
312
+ # 4. Save and upload
313
+ temp_stream = BytesIO()
314
+
315
+ wb.save(temp_stream)
316
+
317
+ temp_stream.seek(0)
318
+
319
+ self.upload_file_from_bytes(temp_stream.getvalue(), excel_file_name, folder_name)
320
+
321
+ print(f"✔ Added row + sorted '{sheet_name}' in '{excel_file_name}'.")
322
+
323
+ def format_and_sort_excel_file(
324
+ self,
325
+ folder_name: str,
326
+ excel_file_name: str,
327
+ sheet_name: str,
328
+ sorting_keys: Optional[List[Dict[str, Any]]] = None,
329
+ font_config: Optional[Dict[int, Dict[str, Any]]] = None,
330
+ bold_rows: Optional[List[int]] = None,
331
+ italic_rows: Optional[List[int]] = None,
332
+ align_horizontal: str = "center",
333
+ align_vertical: str = "center",
334
+ column_widths: Any = "auto",
335
+ freeze_panes: Optional[str] = None,
336
+ ):
337
+ """
338
+ Sorts and formats an Excel worksheet based on provided styling and sorting rules.
339
+
340
+ Params:
341
+ folder_name: Name of the folder where the file resides
342
+ excel_file_name: Name of the excel file
343
+ sheet_name: Name of the sheet that will be sorted
344
+ sorting_keys: List of dicts like [{"key": "A", "ascending": True, "type": "datetime"}]
345
+ bold_rows: List of row numbers to bold (defaults to [1])
346
+ italic_rows: List of row numbers to italicize
347
+ font_config: Dict of row -> font config (overrides bold/italic)
348
+ align_horizontal: Horizontal text alignment
349
+ align_vertical: Vertical text alignment
350
+ column_widths: "auto" or an int to represent a pixel value
351
+ freeze_panes: E.g., "A2" to freeze header row
352
+
353
+ Returns:
354
+ Modified worksheet
355
+ """
356
+
357
+ # Step 1 - Fetch the file to update from SharePoint and load it as a workbook
358
+ # This ensures we don't override any other sheets in the excel file
359
+ binary_file = self.fetch_file_using_open_binary(excel_file_name, folder_name)
360
+ if binary_file is None:
361
+ raise FileNotFoundError(f"File '{excel_file_name}' not found in folder '{folder_name}'.")
362
+
363
+ wb = load_workbook(BytesIO(binary_file))
364
+ if sheet_name not in wb.sheetnames:
365
+ raise ValueError(f"Sheet '{sheet_name}' not found in '{excel_file_name}'")
366
+
367
+ ws = wb[sheet_name]
368
+
369
+ # Step 2 - Read data into DataFrame
370
+ rows = list(ws.iter_rows(values_only=True))
371
+ header, *data_rows = rows
372
+ df = pd.DataFrame(data_rows, columns=header)
373
+
374
+ # Step 3 – Prepare sorting logic
375
+ # For each sorting instruction, we:
376
+ # - Extract the column to sort by (using letter, index, or name)
377
+ # - Convert the column values to the desired data type if specified (str, int, float, datetime)
378
+ # - Track which columns to sort and in which order (ascending or descending)
379
+ #
380
+ # This ensures the DataFrame is sorted correctly, even when types like dates or numbers need conversion.
381
+ if sorting_keys:
382
+ sort_columns = []
383
+ ascending_flags = []
384
+
385
+ for item in sorting_keys:
386
+ key = item.get("key")
387
+ ascending = item.get("ascending", True)
388
+ dtype = item.get("type")
389
+
390
+ if isinstance(key, int):
391
+ col_name = header[key]
392
+
393
+ elif isinstance(key, str) and key.isalpha():
394
+ col_name = header[ord(key.upper()) - ord("A")]
395
+
396
+ else:
397
+ col_name = key
398
+
399
+ sort_columns.append(col_name)
400
+ ascending_flags.append(ascending)
401
+
402
+ if dtype == "datetime":
403
+ df[col_name] = pd.to_datetime(df[col_name], dayfirst=True, errors="coerce")
404
+
405
+ elif dtype == "int":
406
+ df[col_name] = pd.to_numeric(df[col_name], errors="coerce", downcast="integer")
407
+
408
+ elif dtype == "float":
409
+ df[col_name] = pd.to_numeric(df[col_name], errors="coerce", downcast="float")
410
+
411
+ elif dtype == "str":
412
+ df[col_name] = df[col_name].astype(str)
413
+
414
+ # Step 4 – Sort
415
+ df.sort_values(by=sort_columns, ascending=ascending_flags, inplace=True)
416
+
417
+ # Step 5 - Overwrite worksheet
418
+ ws.delete_rows(1, ws.max_row)
419
+
420
+ ws.append(header)
421
+
422
+ for _, row in df.iterrows():
423
+ ws.append(list(row))
424
+
425
+ # Step 6 – Adjust column widths and apply wrapping if needed
426
+ #
427
+ # If column_widths is "auto":
428
+ # - Calculate the max content length in each column and set the column width accordingly (+2 for padding)
429
+ #
430
+ # If column_widths is a single int:
431
+ # - Use it as a global max width across all columns
432
+ # - If content fits, set width based on actual content length
433
+ # - If content exceeds the max width clamp column width and enable wrap_text for that column's cells
434
+ #
435
+ # Then, for wrapped cells, auto-adjust the row height:
436
+ # - Estimate how many lines the wrapped text would occupy and set row height accordingly to ensure all content is visible
437
+ if column_widths in (None, "auto"):
438
+ for col in ws.columns:
439
+ max_len = max(len(str(cell.value or "")) for cell in col)
440
+
441
+ ws.column_dimensions[col[0].column_letter].width = max_len + 2
442
+
443
+ elif isinstance(column_widths, int):
444
+ for col in ws.columns:
445
+ col_letter = col[0].column_letter
446
+
447
+ max_len = max(len(str(cell.value or "")) for cell in col)
448
+
449
+ # If content fits, auto-size
450
+ if max_len + 2 <= column_widths:
451
+ ws.column_dimensions[col_letter].width = max_len + 2
452
+
453
+ # Else, cap width and enable wrap
454
+ else:
455
+ ws.column_dimensions[col_letter].width = column_widths
456
+
457
+ for cell in col:
458
+ cell.alignment = Alignment(wrap_text=True)
459
+
460
+ # Here we handle row height
461
+ for row in ws.iter_rows():
462
+ max_line_count = 1
463
+
464
+ for cell in row:
465
+ if cell.value and cell.alignment and cell.alignment.wrap_text:
466
+ col_letter = cell.column_letter
467
+ col_width = ws.column_dimensions[col_letter].width or 10
468
+ chars_per_line = col_width * 1.2
469
+ lines = str(cell.value).split("\n")
470
+ line_count = sum(math.ceil(len(line) / chars_per_line) for line in lines)
471
+ max_line_count = max(max_line_count, line_count)
472
+
473
+ ws.row_dimensions[row[0].row].height = max_line_count * 20
474
+
475
+ else:
476
+ raise ValueError(f"Column width provided with incorrect datatype - datatype int expected, instead column width is of datatype {type(column_widths)}")
477
+
478
+ # Step 7 - Freeze panes if needed
479
+ if freeze_panes:
480
+ ws.freeze_panes = freeze_panes
481
+
482
+ # Step 8 – Apply base formatting
483
+ # For each cell in the worksheet:
484
+ # - Apply font styling based on either a custom `font_config` (row-specific) or default to bold/italic based on row number (e.g., header rows)
485
+ # - Set horizontal and vertical alignment for consistent layout
486
+ # - Disable text wrapping by default (wrapping will be handled later if needed)
487
+ #
488
+ # This ensures a clean, uniform look across the sheet while allowing for custom styling where defined.
489
+ for row_idx, row in enumerate(ws.iter_rows(), start=1):
490
+ for cell in row:
491
+ if font_config and row_idx in font_config:
492
+ config = font_config[row_idx]
493
+
494
+ cell.font = Font(
495
+ name=config.get("name", "Calibri"),
496
+ size=config.get("size", 11),
497
+ bold=config.get("bold", False),
498
+ italic=config.get("italic", False),
499
+ )
500
+
501
+ else:
502
+ cell.font = Font(
503
+ bold=row_idx in bold_rows if bold_rows else False,
504
+ italic=row_idx in italic_rows if italic_rows else False,
505
+ )
506
+
507
+ cell.alignment = Alignment(
508
+ horizontal=align_horizontal,
509
+ vertical=align_vertical,
510
+ wrap_text=cell.alignment.wrap_text
511
+ )
512
+
513
+ # Step 9 - Save and re-upload
514
+ temp_stream = BytesIO()
515
+
516
+ wb.save(temp_stream)
517
+
518
+ temp_stream.seek(0)
519
+
520
+ self.upload_file_from_bytes(temp_stream.getvalue(), excel_file_name, folder_name)
@@ -99,11 +99,18 @@ class BaseUI:
99
99
 
100
100
  def close_window(self, window_to_close: auto.WindowControl) -> None:
101
101
  """Closes specified window."""
102
+ window_name = window_to_close.Name
102
103
  window_to_close.SetFocus()
103
104
  window_to_close.GetWindowPattern().Close()
104
105
 
105
- pop_up_window = window_to_close.WindowControl(Name="TMT - Afslut")
106
- pop_up_window.SetFocus()
107
- pop_up_window.ButtonControl(Name="Ja").Click(simulateMove=False, waitTime=0)
106
+ # Handle popup when closin main window
107
+ if window_name.lower().startswith("hovedvindue"):
108
108
 
109
- time.sleep(2)
109
+ pop_up_window = window_to_close.WindowControl(Name="TMT - Afslut")
110
+ pop_up_window.SetFocus()
111
+ pop_up_window.ButtonControl(Name="Ja").Click(simulateMove=False, waitTime=0)
112
+
113
+ time.sleep(2)
114
+
115
+ else:
116
+ self.app_window = self.wait_for_control(search_params={'AutomationId': 'FormFront'}, control_type=auto.WindowControl)
@@ -97,6 +97,8 @@ class DocumentHandler(HandlerBase):
97
97
  Under “Print/Flet patienter” → select template → merge → wait for Word to open,
98
98
  convert to PDF, kill WINWORD.EXE, then create_document() with the new PDF.
99
99
  """
100
+ folder_path = rf"{os.environ.get('USERPROFILE')}\AppData\Local\Temp\Care\TMTand"
101
+
100
102
  try:
101
103
  self.open_tab("Stamkort")
102
104
 
@@ -169,8 +171,8 @@ class DocumentHandler(HandlerBase):
169
171
  if new_value != metadata['templateName']:
170
172
  raise ValueError(f"Failed to set the correct status. Expected '{metadata['templateName']}', but got '{new_value}'.")
171
173
 
172
- folder_path = rf"{os.environ.get('USERPROFILE')}\AppData\Local\Temp\Care\TMTand"
173
- shutil.rmtree(folder_path, ignore_errors=True)
174
+ if os.path.exists(folder_path):
175
+ shutil.rmtree(folder_path, ignore_errors=True)
174
176
 
175
177
  form_mail_merge.PaneControl(AutomationId="ButtonMerge").GetLegacyIAccessiblePattern().DoDefaultAction()
176
178
 
@@ -229,7 +231,8 @@ class DocumentHandler(HandlerBase):
229
231
  print(f"Error while creating document from template: {e}")
230
232
  raise
231
233
  finally:
232
- shutil.rmtree(folder_path, ignore_errors=True)
234
+ if os.path.exists(folder_path):
235
+ shutil.rmtree(folder_path, ignore_errors=True)
233
236
 
234
237
  def send_discharge_document_digitalpost(self, metadata: dict) -> None:
235
238
  """
@@ -0,0 +1,278 @@
1
+ """
2
+ Integration tests for the GetOrganized API.
3
+
4
+ These tests validate the following key functionalities:
5
+ 1. Authentication success using CPR lookup
6
+ 2. Contact lookup returns correct citizen profile
7
+ 3. Retrieval of metadata for a specific case (Borgermappe)
8
+ 4. Case lookup using case properties
9
+ 5. Document metadata retrieval
10
+ 6. Document search (legacy and modern search APIs)
11
+
12
+ Expected to run daily to ensure API stability and data consistency.
13
+
14
+ Required environment variables:
15
+ - GO_API_ENDPOINT: Base URL for GetOrganized API
16
+ - GO_API_USERNAME: API username for authentication
17
+ - GO_API_PASSWORD: API password
18
+ - DADJ_FULL_NAME: Full name of test citizen
19
+ - DADJ_SSN: CPR number of test citizen
20
+ - DADJ_GO_ID: Internal ID of test citizen in GO
21
+ - DADJ_BORGERMAPPE_SAGS_ID: Case ID of the test citizen's "Borgermappe"
22
+ """
23
+
24
+ import os
25
+ import xml.etree.ElementTree as ET
26
+ import requests
27
+ import pytest
28
+
29
+ from mbu_dev_shared_components.getorganized import contacts
30
+ from mbu_dev_shared_components.getorganized import cases
31
+ from mbu_dev_shared_components.getorganized import documents
32
+ from mbu_dev_shared_components.getorganized import objects
33
+
34
+
35
+ # -------------------------------
36
+ # Helper to load environment vars safely
37
+ # -------------------------------
38
+ def _get_cfg(key: str) -> str:
39
+ val = os.getenv(key)
40
+
41
+ if not val:
42
+ pytest.skip(f"env var '{key}' not set → skipping integration test")
43
+
44
+ return val
45
+
46
+
47
+ @pytest.fixture(scope="module")
48
+ def go_env():
49
+ """
50
+ Loads required environment variables for the GetOrganized API tests.
51
+ Skips tests if any required variable is missing.
52
+ """
53
+
54
+ return {
55
+ "endpoint": _get_cfg("GO_API_ENDPOINT"),
56
+ "username": _get_cfg("GO_API_USERNAME"),
57
+ "password": _get_cfg("GO_API_PASSWORD"),
58
+ "full_name": _get_cfg("DADJ_FULL_NAME"),
59
+ "ssn": _get_cfg("DADJ_SSN"),
60
+ "go_id": _get_cfg("DADJ_GO_ID"),
61
+ "case_id": _get_cfg("DADJ_BORGERMAPPE_SAGS_ID"),
62
+ }
63
+
64
+
65
+ def test_authentication_success(go_env):
66
+ """
67
+ Ensures valid credentials can access the API without 401/403.
68
+ """
69
+
70
+ resp = contacts.contact_lookup(
71
+ person_ssn=go_env["ssn"],
72
+ api_endpoint=f"{go_env['endpoint']}/_goapi/contacts/readitem",
73
+ api_username=go_env["username"],
74
+ api_password=go_env["password"],
75
+ )
76
+
77
+ assert resp.status_code != 401
78
+ assert resp.status_code != 403
79
+ assert resp.ok
80
+
81
+
82
+ def test_contact_lookup_returns_expected_name(go_env):
83
+ """
84
+ Verifies that contact lookup returns correct full name, ID, and CPR.
85
+ """
86
+
87
+ resp = contacts.contact_lookup(
88
+ person_ssn=go_env["ssn"],
89
+ api_endpoint=f"{go_env['endpoint']}/_goapi/contacts/readitem",
90
+ api_username=go_env["username"],
91
+ api_password=go_env["password"],
92
+ )
93
+
94
+ assert resp.ok
95
+
96
+ data = resp.json()
97
+
98
+ assert data["FullName"] == go_env["full_name"]
99
+ assert data["ID"] == go_env["go_id"]
100
+ assert data["CPR"] == go_env["ssn"]
101
+
102
+
103
+ def test_case_metadata_structure(go_env):
104
+ """
105
+ Validates the metadata structure of a known 'Borgermappe' case.
106
+ """
107
+
108
+ resp = cases.get_case_metadata(
109
+ api_endpoint=f"{go_env['endpoint']}/_goapi/Cases/Metadata/{go_env['case_id']}",
110
+ api_username=go_env["username"],
111
+ api_password=go_env["password"],
112
+ )
113
+
114
+ assert resp.ok
115
+
116
+ # Extract XML metadata and parse attributes
117
+ resp_metadata_xml = resp.json().get("Metadata")
118
+
119
+ assert resp_metadata_xml
120
+
121
+ data = ET.fromstring(resp_metadata_xml).attrib
122
+
123
+ assert data["ows_CaseID"] == go_env["case_id"]
124
+
125
+ assert data["ows_CaseCategory"] == "Borgermappe"
126
+
127
+ assert data["ows_CCMContactData"] == f"{go_env['full_name']};#{go_env['go_id']};#{go_env['ssn']};#;#"
128
+
129
+ assert data["ows_CCMContactData_CPR"] == go_env["ssn"]
130
+
131
+
132
+ def test_find_case_by_case_properties(go_env):
133
+ """
134
+ Searches for a case using known properties of the test citizen.
135
+ """
136
+
137
+ case_data_json = objects.CaseDataJson()
138
+
139
+ case_data = case_data_json.search_citizen_folder_data_json(
140
+ case_type_prefix="BOR",
141
+ person_full_name=go_env["full_name"],
142
+ person_id=go_env["go_id"],
143
+ person_ssn=go_env["ssn"]
144
+ )
145
+
146
+ resp = cases.find_case_by_case_properties(
147
+ case_data=case_data,
148
+ api_endpoint=f"{go_env['endpoint']}/_goapi/Cases/FindByCaseProperties",
149
+ api_username=go_env["username"],
150
+ api_password=go_env["password"],
151
+ )
152
+
153
+ assert resp.ok
154
+
155
+ data = resp.json().get("CasesInfo")
156
+
157
+ assert isinstance(data, list)
158
+
159
+ assert len(data) == 1
160
+
161
+ assert data[0].get("CaseID") == go_env["case_id"]
162
+
163
+
164
+ def test_get_document_metadata(go_env):
165
+ """
166
+ Fetches metadata for a known document tied to the test citizen.
167
+ """
168
+ document_id = 14583373 # ID must be valid for test profile
169
+
170
+ resp = documents.get_document_metadata(
171
+ api_endpoint=f"{go_env['endpoint']}/_goapi/Documents/Metadata/{document_id}",
172
+ api_username=go_env["username"],
173
+ api_password=go_env["password"],
174
+ )
175
+
176
+ assert resp.ok
177
+
178
+ data = ET.fromstring(resp.json().get("Metadata")).attrib
179
+
180
+ assert "ows_Title" in data
181
+
182
+
183
+ def test_search_documents(go_env):
184
+ """
185
+ Performs a legacy document search using full name + CPR.
186
+ """
187
+
188
+ search_term = f"{go_env['full_name']} {go_env['ssn']}"
189
+
190
+ resp = documents.search_documents(
191
+ search_term=search_term,
192
+ api_endpoint=f"{go_env['endpoint']}/_goapi/Search/Results",
193
+ api_username=go_env["username"],
194
+ api_password=go_env["password"],
195
+ )
196
+
197
+ assert resp.ok
198
+
199
+ results = resp.json().get("Rows").get("Results")
200
+ total_rows = resp.json().get("TotalRows")
201
+
202
+ assert isinstance(results, list)
203
+
204
+ if total_rows == 0:
205
+ assert len(results) == 0
206
+
207
+ else:
208
+ assert len(results) == total_rows
209
+
210
+ for doc in results:
211
+ assert "title" in doc
212
+ assert "created" in doc
213
+ assert "caseid" in doc
214
+
215
+
216
+ def _validate_modern_search_response(resp: requests.Response):
217
+ """
218
+ Helper: Validates structure of modern search response.
219
+ """
220
+
221
+ assert resp.ok
222
+
223
+ json_data = resp.json()
224
+
225
+ results = json_data.get("results", {}).get("Results", [])
226
+
227
+ total_rows = json_data.get("totalRows")
228
+
229
+ assert isinstance(results, list)
230
+
231
+ if total_rows == 0:
232
+ assert len(results) == 0
233
+
234
+ else:
235
+ assert len(results) == total_rows
236
+ for doc in results:
237
+
238
+ assert "title" in doc
239
+ assert "created" in doc
240
+ assert "caseid" in doc
241
+
242
+
243
+ def test_modern_search(go_env):
244
+ """
245
+ Runs two modern searches: one without date filter, one with date filter.
246
+ """
247
+
248
+ search_term = f"{go_env['full_name']} {go_env['ssn']}"
249
+
250
+ # Search 1: All-time
251
+ resp_1 = documents.modern_search(
252
+ page_index=0,
253
+ search_term=search_term,
254
+ start_date=None,
255
+ end_date=None,
256
+ only_items=False,
257
+ case_type_prefix="BOR",
258
+ api_endpoint=f"{go_env['endpoint']}/_goapi/search/ExecuteModernSearch",
259
+ api_username=go_env["username"],
260
+ api_password=go_env["password"],
261
+ )
262
+
263
+ # Search 2: Specific March 2025 range
264
+ resp_2 = documents.modern_search(
265
+ page_index=0,
266
+ search_term=search_term,
267
+ start_date="2025-03-01",
268
+ end_date="2025-03-31",
269
+ only_items=False,
270
+ case_type_prefix="BOR",
271
+ api_endpoint=f"{go_env['endpoint']}/_goapi/search/ExecuteModernSearch",
272
+ api_username=go_env["username"],
273
+ api_password=go_env["password"],
274
+ )
275
+
276
+ _validate_modern_search_response(resp_1)
277
+
278
+ _validate_modern_search_response(resp_2)
@@ -0,0 +1,124 @@
1
+ """
2
+ Unit tests for data structure creation methods in CaseDataJson.
3
+ These are pure functions that return formatted dictionaries, used in API requests.
4
+
5
+ Should run on pull requests to ensure the data structure is correct.
6
+ """
7
+
8
+ import pytest
9
+ from mbu_dev_shared_components.getorganized.objects import CaseDataJson
10
+
11
+
12
+ @pytest.fixture
13
+ def case_data_handler():
14
+ """
15
+ Fixture to provide an instance of CaseDataJson for testing.
16
+ """
17
+ return CaseDataJson()
18
+
19
+
20
+ def test_search_citizen_folder_data_json(case_data_handler: CaseDataJson):
21
+ """
22
+ Ensure that the search_citizen_folder_data_json method correctly builds
23
+ a search structure for a citizen's full data and filters by 'Borgermappe' case category.
24
+ """
25
+
26
+ result = case_data_handler.search_citizen_folder_data_json(
27
+ case_type_prefix="BOR",
28
+ person_full_name="Test Person",
29
+ person_id="12345",
30
+ person_ssn="0101011234"
31
+ )
32
+
33
+ assert result["LogicalOperator"] == "AND"
34
+ assert result["ExcludeDeletedCases"] == "True"
35
+ assert result["ReturnCasesNumber"] == "1"
36
+
37
+ assert {
38
+ "InternalName": "ows_CCMContactData",
39
+ "Value": "Test Person;#12345;#0101011234;#;#",
40
+ "DataType": "Text",
41
+ "ComparisonType": "Equal",
42
+ "IsMultiValue": "False"
43
+ } in result["FieldProperties"]
44
+
45
+ assert {
46
+ "InternalName": "ows_CaseCategory",
47
+ "Value": "Borgermappe",
48
+ "DataType": "Text",
49
+ "ComparisonType": "Equal",
50
+ "IsMultiValue": "False"
51
+ } in result["FieldProperties"]
52
+
53
+
54
+ def test_generic_search_case_data_json_with_name_and_extra_field(case_data_handler: CaseDataJson):
55
+ """
56
+ Ensure that generic_search_case_data_json assembles a correct structure when using:
57
+ - a custom case_type_prefix,
58
+ - a non-default number of return cases,
59
+ - full citizen data,
60
+ - and extra field properties.
61
+ """
62
+
63
+ field_properties = {
64
+ "ows_Title": "Lønbilag"
65
+ }
66
+
67
+ result = case_data_handler.generic_search_case_data_json(
68
+ case_type_prefix="PER",
69
+ person_full_name="Test Person",
70
+ person_id="12345",
71
+ person_ssn="0101011234",
72
+ include_name=True,
73
+ returned_cases_number="5",
74
+ field_properties=field_properties
75
+ )
76
+
77
+ assert result["CaseTypePrefixes"] == ["PER"]
78
+
79
+ assert {
80
+ "InternalName": "ows_CCMContactData",
81
+ "Value": "Test Person;#12345;#0101011234;#;#",
82
+ "DataType": "Text",
83
+ "ComparisonType": "Equal",
84
+ "IsMultiValue": "False"
85
+ } in result["FieldProperties"]
86
+
87
+ assert result["ReturnCasesNumber"] == "5"
88
+
89
+ assert {
90
+ "InternalName": "ows_Title",
91
+ "Value": "Lønbilag",
92
+ "DataType": "Text",
93
+ "ComparisonType": "Equal",
94
+ "IsMultiValue": "False"
95
+ } in result["FieldProperties"]
96
+
97
+
98
+ def test_generic_search_case_data_json_without_name(case_data_handler: CaseDataJson):
99
+ """
100
+ Ensure that generic_search_case_data_json builds correct contact data when include_name=False,
101
+ and only includes one field (ows_CCMContactData) by default when no additional fields are passed.
102
+ """
103
+
104
+ result = case_data_handler.generic_search_case_data_json(
105
+ case_type_prefix="BOR",
106
+ person_full_name="Daniel Tester",
107
+ person_id="12345",
108
+ person_ssn="0101011234",
109
+ include_name=False
110
+ )
111
+
112
+ assert result["CaseTypePrefixes"] == ["BOR"]
113
+
114
+ assert {
115
+ "InternalName": "ows_CCMContactData",
116
+ "Value": ";#12345;#0101011234;#;#",
117
+ "DataType": "Text",
118
+ "ComparisonType": "Equal",
119
+ "IsMultiValue": "False"
120
+ } in result["FieldProperties"]
121
+
122
+ assert result["ReturnCasesNumber"] == "1"
123
+
124
+ assert len(result["FieldProperties"]) == 1
@@ -0,0 +1,188 @@
1
+ """
2
+ Integration tests for verifying Microsoft Office Excel functionality through SharePoint.
3
+
4
+ Tests included:
5
+ 1. `test_list_files_from_sharepoint_folder`:
6
+ - Verifies that a known test Excel file exists in the SharePoint folder.
7
+
8
+ 2. `test_append_row_to_excel`:
9
+ - Appends a row to a specific sheet in the Excel file.
10
+ - Verifies the row was successfully added.
11
+
12
+ 3. `test_format_and_sort_excel_file`:
13
+ - Sorts the Excel sheet by date/time (descending).
14
+ - Applies formatting (bold headers, auto column widths).
15
+ - Verifies that sorting has changed the order.
16
+
17
+ Important constants:
18
+ - FOLDER_NAME = "MSOffice tests"
19
+ - FILE_NAME = "Test_Append_Rows.xlsx"
20
+ - SHEET_NAME = "Upload logs"
21
+ - HEADERS = ["Upload date", "Upload time"]
22
+
23
+ Requires environment variables:
24
+ - MSOFFICE_USERNAME
25
+ - MSOFFICE_PASSWORD
26
+ """
27
+
28
+ import os
29
+ import datetime
30
+
31
+ from io import BytesIO
32
+
33
+ import pytest
34
+ import openpyxl
35
+
36
+ from mbu_dev_shared_components.msoffice365.sharepoint_api.files import Sharepoint
37
+
38
+ FOLDER_NAME = "MSOffice tests"
39
+ CURRENT_DATE = datetime.datetime.now().strftime("%d-%m-%Y")
40
+ CURRENT_TIME = datetime.datetime.now().strftime("%H:%M")
41
+
42
+
43
+ @pytest.fixture(scope="module")
44
+ def sharepoint_api():
45
+ """
46
+ Authenticates with SharePoint and returns a Sharepoint API instance.
47
+ Skips test if required environment variables are missing.
48
+ """
49
+
50
+ def _get_cfg(key: str) -> str:
51
+ val = os.getenv(key)
52
+
53
+ if not val:
54
+ pytest.skip(f"env var '{key}' not set → skipping integration test")
55
+
56
+ return val
57
+
58
+ username = _get_cfg("MSOFFICE_USERNAME")
59
+ password = _get_cfg("MSOFFICE_PASSWORD")
60
+
61
+ site_url = "https://aarhuskommune.sharepoint.com"
62
+ site_name = "MBURPA"
63
+ document_library = "Delte dokumenter"
64
+
65
+ sp = Sharepoint(
66
+ username=username,
67
+ password=password,
68
+ site_url=site_url,
69
+ site_name=site_name,
70
+ document_library=document_library
71
+ )
72
+
73
+ assert sp.ctx is not None, "SharePoint authentication failed"
74
+
75
+ return sp
76
+
77
+
78
+ @pytest.mark.dependency(name="list_files")
79
+ def test_list_files_from_sharepoint_folder(sharepoint_api: Sharepoint):
80
+ """
81
+ Test 1: Check that the expected Excel file exists in the SharePoint folder.
82
+ """
83
+
84
+ files = sharepoint_api.fetch_files_list(FOLDER_NAME)
85
+
86
+ file_names = [f["Name"] for f in files]
87
+
88
+ assert "Test_Append_Rows.xlsx" in file_names
89
+
90
+
91
+ @pytest.mark.dependency(name="append_row", depends=["list_files"])
92
+ def test_append_row_to_excel(sharepoint_api: Sharepoint):
93
+ """
94
+ Test 2: Append a row to the Excel file and verify it was added correctly.
95
+ """
96
+
97
+ file_name = "Test_Append_Rows.xlsx"
98
+
99
+ sheet_name = "Upload logs"
100
+
101
+ headers = ["Upload date", "Upload time"]
102
+
103
+ data = {
104
+ "Upload date": CURRENT_DATE,
105
+ "Upload time": CURRENT_TIME
106
+ }
107
+
108
+ # Append the row to the SharePoint-hosted Excel file
109
+ sharepoint_api.append_row_to_sharepoint_excel(
110
+ required_headers=headers,
111
+ folder_name=FOLDER_NAME,
112
+ excel_file_name=file_name,
113
+ sheet_name=sheet_name,
114
+ new_row=data,
115
+ )
116
+
117
+ # Fetch the updated file and check that the last row matches the new data
118
+ binary_file = sharepoint_api.fetch_file_using_open_binary(file_name, FOLDER_NAME)
119
+
120
+ wb = openpyxl.load_workbook(BytesIO(binary_file))
121
+
122
+ ws = wb[sheet_name]
123
+
124
+ newest_row = list(ws.iter_rows(values_only=True))[-1]
125
+
126
+ assert newest_row[0] == CURRENT_DATE
127
+ assert newest_row[1] == CURRENT_TIME
128
+
129
+
130
+ @pytest.mark.dependency(depends=["append_row"])
131
+ def test_format_and_sort_excel_file(sharepoint_api: Sharepoint):
132
+ """
133
+ Test 3: Format and sort the Excel file by date and time.
134
+ Ensures that sorting changes the order and formatting is applied.
135
+ """
136
+
137
+ file_name = "Test_Append_Rows.xlsx"
138
+
139
+ sheet_name = "Upload logs"
140
+
141
+ # Download the file before sorting to capture original row order
142
+ test_file = sharepoint_api.fetch_file_using_open_binary(file_name, FOLDER_NAME)
143
+
144
+ wb = openpyxl.load_workbook(BytesIO(test_file))
145
+
146
+ ws = wb[sheet_name]
147
+
148
+ all_rows = list(ws.iter_rows(values_only=True))
149
+
150
+ top_before = all_rows[1] # First data row
151
+
152
+ bottom_before = all_rows[-1] # Last data row
153
+
154
+ # Sort by first two columns (date, time) in descending order
155
+ sorting_keys = [
156
+ {"key": "A", "ascending": False, "type": "str"},
157
+ {"key": "B", "ascending": False, "type": "str"},
158
+ ]
159
+
160
+ # Apply sorting and formatting
161
+ sharepoint_api.format_and_sort_excel_file(
162
+ folder_name=FOLDER_NAME,
163
+ excel_file_name=file_name,
164
+ sheet_name=sheet_name,
165
+ sorting_keys=sorting_keys,
166
+ bold_rows=[1],
167
+ column_widths="auto",
168
+ )
169
+
170
+ # Re-fetch the file after sorting and verify order has changed
171
+ test_file = sharepoint_api.fetch_file_using_open_binary(file_name, FOLDER_NAME)
172
+
173
+ wb = openpyxl.load_workbook(BytesIO(test_file))
174
+
175
+ ws = wb[sheet_name]
176
+
177
+ all_rows = list(ws.iter_rows(values_only=True))
178
+
179
+ top_after = all_rows[1]
180
+
181
+ bottom_after = all_rows[-1]
182
+
183
+ # Check that sorting actually changed the row order
184
+ assert top_before != top_after
185
+ assert bottom_before != bottom_after
186
+
187
+ # Check that the top row after sorting is the one just added
188
+ assert top_after[0] == CURRENT_DATE
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mbu_dev_shared_components
3
- Version: 2.4.2
3
+ Version: 2.4.4
4
4
  Summary: Shared components to use in RPA projects
5
5
  Author-email: MBU <rpa@mbu.aarhus.dk>
6
6
  License-Expression: MIT
@@ -19,7 +19,11 @@ Requires-Dist: uiautomation
19
19
  Requires-Dist: pillow
20
20
  Requires-Dist: psutil
21
21
  Requires-Dist: docx2pdf
22
+ Requires-Dist: pandas>=2.2.3
22
23
  Requires-Dist: rawpy
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-dependency>=0.5.1; extra == "dev"
23
27
  Dynamic: license-file
24
28
 
25
29
  # MBU Dev Shared Components
@@ -49,6 +49,9 @@ mbu_dev_shared_components/solteqtand/application/journal_note.py
49
49
  mbu_dev_shared_components/solteqtand/application/patient.py
50
50
  mbu_dev_shared_components/solteqtand/database/__init__.py
51
51
  mbu_dev_shared_components/solteqtand/database/db_handler.py
52
+ mbu_dev_shared_components/tests/go_tests/go_integration_tests.py
53
+ mbu_dev_shared_components/tests/go_tests/objects_tests.py
54
+ mbu_dev_shared_components/tests/msoffice_tests/msoffice_integration_tests.py
52
55
  mbu_dev_shared_components/utils/__init__.py
53
56
  mbu_dev_shared_components/utils/db_stored_procedure_executor.py
54
57
  mbu_dev_shared_components/utils/fernet_encryptor.py
@@ -8,4 +8,9 @@ uiautomation
8
8
  pillow
9
9
  psutil
10
10
  docx2pdf
11
+ pandas>=2.2.3
11
12
  rawpy
13
+
14
+ [dev]
15
+ pytest>=7.0
16
+ pytest-dependency>=0.5.1
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
 
9
9
  [project]
10
10
  name = "mbu_dev_shared_components"
11
- version = "2.4.2" # Specify the version manually here
11
+ version = "2.4.4" # Specify the version manually here
12
12
  authors = [
13
13
  { name="MBU", email="rpa@mbu.aarhus.dk" },
14
14
  ]
@@ -31,5 +31,12 @@ dependencies = [
31
31
  "pillow",
32
32
  "psutil",
33
33
  "docx2pdf",
34
+ "pandas >= 2.2.3",
34
35
  "rawpy",
35
36
  ]
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest >= 7.0",
41
+ "pytest-dependency >= 0.5.1"
42
+ ]