pyxecm 3.0.1__py3-none-any.whl → 3.1.1__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.

Potentially problematic release.


This version of pyxecm might be problematic. Click here for more details.

Files changed (52) hide show
  1. pyxecm/avts.py +4 -4
  2. pyxecm/coreshare.py +14 -15
  3. pyxecm/helper/data.py +2 -1
  4. pyxecm/helper/web.py +11 -11
  5. pyxecm/helper/xml.py +41 -10
  6. pyxecm/otac.py +1 -1
  7. pyxecm/otawp.py +19 -19
  8. pyxecm/otca.py +878 -70
  9. pyxecm/otcs.py +1716 -349
  10. pyxecm/otds.py +332 -153
  11. pyxecm/otkd.py +4 -4
  12. pyxecm/otmm.py +1 -1
  13. pyxecm/otpd.py +246 -30
  14. {pyxecm-3.0.1.dist-info → pyxecm-3.1.1.dist-info}/METADATA +2 -1
  15. pyxecm-3.1.1.dist-info/RECORD +82 -0
  16. pyxecm_api/app.py +45 -35
  17. pyxecm_api/auth/functions.py +2 -2
  18. pyxecm_api/auth/router.py +2 -3
  19. pyxecm_api/common/functions.py +67 -12
  20. pyxecm_api/settings.py +0 -8
  21. pyxecm_api/terminal/router.py +1 -1
  22. pyxecm_api/v1_csai/router.py +33 -18
  23. pyxecm_customizer/browser_automation.py +161 -79
  24. pyxecm_customizer/customizer.py +43 -25
  25. pyxecm_customizer/guidewire.py +422 -8
  26. pyxecm_customizer/k8s.py +23 -27
  27. pyxecm_customizer/knowledge_graph.py +498 -20
  28. pyxecm_customizer/m365.py +45 -44
  29. pyxecm_customizer/payload.py +1723 -1188
  30. pyxecm_customizer/payload_list.py +3 -0
  31. pyxecm_customizer/salesforce.py +122 -79
  32. pyxecm_customizer/servicenow.py +27 -7
  33. pyxecm_customizer/settings.py +3 -1
  34. pyxecm_customizer/successfactors.py +2 -2
  35. pyxecm_customizer/translate.py +1 -1
  36. pyxecm-3.0.1.dist-info/RECORD +0 -96
  37. pyxecm_api/agents/__init__.py +0 -7
  38. pyxecm_api/agents/app.py +0 -13
  39. pyxecm_api/agents/functions.py +0 -119
  40. pyxecm_api/agents/models.py +0 -10
  41. pyxecm_api/agents/otcm_knowledgegraph/__init__.py +0 -1
  42. pyxecm_api/agents/otcm_knowledgegraph/functions.py +0 -85
  43. pyxecm_api/agents/otcm_knowledgegraph/models.py +0 -61
  44. pyxecm_api/agents/otcm_knowledgegraph/router.py +0 -74
  45. pyxecm_api/agents/otcm_user_agent/__init__.py +0 -1
  46. pyxecm_api/agents/otcm_user_agent/models.py +0 -20
  47. pyxecm_api/agents/otcm_user_agent/router.py +0 -65
  48. pyxecm_api/agents/otcm_workspace_agent/__init__.py +0 -1
  49. pyxecm_api/agents/otcm_workspace_agent/models.py +0 -40
  50. pyxecm_api/agents/otcm_workspace_agent/router.py +0 -200
  51. {pyxecm-3.0.1.dist-info → pyxecm-3.1.1.dist-info}/WHEEL +0 -0
  52. {pyxecm-3.0.1.dist-info → pyxecm-3.1.1.dist-info}/entry_points.txt +0 -0
@@ -30,6 +30,7 @@ class KnowledgeGraph:
30
30
 
31
31
  WORKSPACE_ID_FIELD = "id"
32
32
  WORKSPACE_NAME_FIELD = "name"
33
+ WORKSPACE_DESCRIPTION_FIELD = "description"
33
34
  WORKSPACE_TYPE_FIELD = "wnf_wksp_type_id"
34
35
 
35
36
  def __init__(self, otcs_object: OTCS, ontology: dict[tuple[str, str, str], list[str]] | None = None) -> None:
@@ -76,16 +77,35 @@ class KnowledgeGraph:
76
77
  self._type_graph_inverted = self.invert_type_graph(self._type_graph)
77
78
 
78
79
  # This include the pandas data frames for the node and edge INSTANCES:
79
- self._nodes = Data() # columns=["id", "name", "type"])
80
- self._edges = Data() # (columns=["source", "target", "relationship"])
80
+ self._nodes = Data() # columns=["id", "name", "type", "description", "attributes"])
81
+ self._edges = Data() # (columns=["source_type", "source_id", "target_type", "target_id", "relationship_type", "relationship_semantics"])
81
82
 
82
83
  # Create a simple dictionary with all workspace types to easily
83
84
  # lookup the workspace type name (value) by the workspace type ID (key):
84
85
  workspace_types = self._otcs.get_workspace_types()
85
- self.workspace_types = {
86
- wt["data"]["properties"]["wksp_type_id"]: wt["data"]["properties"]["wksp_type_name"]
87
- for wt in workspace_types["results"]
88
- }
86
+ if workspace_types:
87
+ self._workspace_types = {
88
+ wt["data"]["properties"]["wksp_type_id"]: wt["data"]["properties"]["wksp_type_name"]
89
+ for wt in workspace_types.get("results", [])
90
+ }
91
+ else:
92
+ self._workspace_types = {}
93
+
94
+ self._attribute_schemas = {}
95
+ self._graph_ready = False
96
+
97
+ # end method definition
98
+
99
+ def is_graph_ready(self) -> bool:
100
+ """Return whether or not the graph has been built and is ready for queries.
101
+
102
+ Returns:
103
+ bool:
104
+ True if the graph is ready, False otherwise.
105
+
106
+ """
107
+
108
+ return self._graph_ready
89
109
 
90
110
  # end method definition
91
111
 
@@ -102,6 +122,28 @@ class KnowledgeGraph:
102
122
 
103
123
  # end method definition
104
124
 
125
+ def get_node_by_id(self, node_id: int) -> dict | None:
126
+ """Return the graph node with the given ID as a Data object.
127
+
128
+ Args:
129
+ node_id (int):
130
+ ID of the node to retrieve.
131
+
132
+ Returns:
133
+ dict | None:
134
+ Dictionary representation of the node of the Knowledge Graph, or None if not found.
135
+
136
+ """
137
+
138
+ data_frame = self._nodes.get_data_frame()
139
+ node_data = data_frame.loc[data_frame["id"] == node_id]
140
+ if not node_data.empty:
141
+ return node_data.to_dict(orient="records")[0]
142
+
143
+ return None
144
+
145
+ # end method definition
146
+
105
147
  def get_edges(self) -> Data:
106
148
  """Return the graph edges as a Data object.
107
149
 
@@ -141,6 +183,238 @@ class KnowledgeGraph:
141
183
 
142
184
  # end method definition
143
185
 
186
+ def get_workspace_types(self) -> dict:
187
+ """Return the workspace types dictionary.
188
+
189
+ Returns:
190
+ dict:
191
+ Dictionary mapping workspace type IDs to workspace type names.
192
+
193
+ """
194
+
195
+ return self._workspace_types
196
+
197
+ # end method definition
198
+
199
+ def get_workspace_type_names(self) -> list:
200
+ """Return all workspace type names as a list.
201
+
202
+ Returns:
203
+ list:
204
+ All workspace type names.
205
+
206
+ """
207
+
208
+ return list(self._workspace_types.values())
209
+
210
+ # end method definition
211
+
212
+ def get_workspace_type_name(self, workspace_type_id: str) -> str | None:
213
+ """Return the workspace type name for a given workspace type ID.
214
+
215
+ Args:
216
+ workspace_type_id (str):
217
+ The ID of the workspace type.
218
+
219
+ Returns:
220
+ str | None:
221
+ The name of the workspace type, or None if not found.
222
+
223
+ """
224
+
225
+ if self._workspace_types is None:
226
+ return None
227
+
228
+ return self._workspace_types.get(workspace_type_id, None)
229
+
230
+ # end method definition
231
+
232
+ def get_attribute_schemas(self) -> dict:
233
+ """Return the attribute schemas dictionary.
234
+
235
+ Returns:
236
+ dict:
237
+ Dictionary mapping attribute names to their schemas.
238
+
239
+ """
240
+
241
+ return self._attribute_schemas
242
+
243
+ # end method definition
244
+
245
+ def get_category_names(self) -> list:
246
+ """Return all category names as a list.
247
+
248
+ Returns:
249
+ list:
250
+ All category item names.
251
+
252
+ """
253
+
254
+ return list(self._attribute_schemas.keys())
255
+
256
+ # end method definition
257
+
258
+ def get_category_attributes(self, category: str) -> list:
259
+ """Return all attribute names as a list.
260
+
261
+ Attributes in a set use "<set_name>:<attribute_name>" as key.
262
+
263
+ Returns:
264
+ list:
265
+ All attribute names of a given category.
266
+
267
+ """
268
+
269
+ if not self._attribute_schemas:
270
+ self.build_categories()
271
+
272
+ return list(self._attribute_schemas.get(category, {}).get("attributes", {}).keys())
273
+
274
+ # end method definition
275
+
276
+ def get_category_id(self, category: str) -> str | None:
277
+ """Return the category ID for a category name.
278
+
279
+ Args:
280
+ category (str):
281
+ The category of the attribute.
282
+
283
+ """
284
+ return self.get_attribute_id(category=category)
285
+
286
+ # end method definition
287
+
288
+ def get_attribute_id(
289
+ self, category: str, attribute: str | None = None, set_attribute: str | None = None, row: int | None = None
290
+ ) -> str | None:
291
+ """Return the attribute ID (or category ID if attribute is None).
292
+
293
+ Args:
294
+ category (str):
295
+ The category of the attribute.
296
+ attribute (str):
297
+ The name of the attribute.
298
+ set_attribute (str | None, optional):
299
+ The set of the attribute.
300
+ row (int | None, optional):
301
+ The row number to retrieve (1-based index for set lines).
302
+
303
+ Returns:
304
+ str:
305
+ OTCS category ID or attribute ID.
306
+
307
+ """
308
+
309
+ if not self._attribute_schemas:
310
+ self.build_categories()
311
+
312
+ # Lookup the category schema by the category name:
313
+ category = self._attribute_schemas.get(category, {})
314
+ if not category:
315
+ return None
316
+ if not attribute:
317
+ # If no specific attribute is requested return the category ID:
318
+ return category.get("id", None)
319
+ attributes = category.get("attributes", {})
320
+ # Construct the attribute lookup key. For set attributes we use the format "Set:Attribute".
321
+ lookup_key = attribute if not set_attribute else f"{set_attribute}:{attribute}"
322
+ if lookup_key not in attributes:
323
+ self.logger.error("Attribute '%s' not found in category '%s'!", lookup_key, category.get("id", "unknown"))
324
+ return None
325
+
326
+ key = attributes[lookup_key]
327
+
328
+ if row is not None and "_x_" in key:
329
+ # We have to adjust for 0-based index:
330
+ key = key.replace("_x_", "_{}_".format(row))
331
+ elif row is not None:
332
+ self.logger.warning("Row specified for non-multi-line set attribute - ignoring row.")
333
+
334
+ return key
335
+
336
+ # end method definition
337
+
338
+ def get_result_values(self, response: dict, key: str, sub_keys: list[str] | None = None) -> list | None:
339
+ """Read all values with a given key from the Knowledge Graph Query.
340
+
341
+ Args:
342
+ response (dict):
343
+ Knowledge Graph query result. Example:
344
+ 28038: {
345
+ 'id': '28038',
346
+ 'name': 'C.E.B. Berlin SE (10020)',
347
+ 'attributes': {'Vendor': {...}}
348
+ 'path': ['Material', 'Purchase Order', 'Vendor']
349
+ }
350
+ key (str):
351
+ Key to find (e.g., "name", "attributes").
352
+ sub_keys (list[str] | None, optional):
353
+ Sub keys to lookup data in a dictionary like "attributes".
354
+
355
+ Returns:
356
+ list | None:
357
+ Value list of the item with the given key, or None if no value is found.
358
+
359
+ """
360
+
361
+ # First do some sanity checks:
362
+ if not response:
363
+ self.logger.debug("Empty query response - returning None")
364
+ return None
365
+
366
+ # Initialize results variable:
367
+ results = []
368
+
369
+ # Loop through all graph response values:
370
+ for value in response.values():
371
+ # Check if the key does actually exist in the current value:
372
+ if key not in value:
373
+ continue
374
+
375
+ item = value[key]
376
+
377
+ # If sub-keys exist, loop through them and expand sub-dicts:
378
+ if sub_keys:
379
+ for sub_key in sub_keys:
380
+ if not isinstance(item, dict) or sub_key not in item:
381
+ item = None
382
+ break
383
+ item = item[sub_key]
384
+
385
+ # If a final item is found after expansion add it to the result list:
386
+ if item is not None:
387
+ results.append(item)
388
+
389
+ # We want to return None instead of an empty list:
390
+ return results or None
391
+
392
+ # end method definition
393
+
394
+ def get_result_values_iterator(
395
+ self,
396
+ response: dict,
397
+ ) -> iter:
398
+ """Get an iterator object that can be used to traverse through OTCS responses.
399
+
400
+ Args:
401
+ response (dict):
402
+ REST API response object.
403
+
404
+ Returns:
405
+ list | None:
406
+ Value list of the item with the given key, or None if no value is found.
407
+
408
+ """
409
+
410
+ # First do some sanity checks:
411
+ if not response:
412
+ return
413
+
414
+ yield from response.values()
415
+
416
+ # end method definition
417
+
144
418
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="build_type_graph")
145
419
  def build_type_graph(self, directions: list | None = None) -> dict[str, set[str]]:
146
420
  """Construct a directed type-level graph from the ontology.
@@ -346,19 +620,30 @@ class KnowledgeGraph:
346
620
 
347
621
  workspace_id = self._otcs.get_result_value(response=workspace_node, key=self.WORKSPACE_ID_FIELD)
348
622
  workspace_name = self._otcs.get_result_value(response=workspace_node, key=self.WORKSPACE_NAME_FIELD)
349
- workspace_type = self.workspace_types[
623
+ workspace_description = self._otcs.get_result_value(
624
+ response=workspace_node, key=self.WORKSPACE_DESCRIPTION_FIELD
625
+ )
626
+ # We use the cached names of the workspace types:
627
+ workspace_type = self._workspace_types[
350
628
  self._otcs.get_result_value(response=workspace_node, key=self.WORKSPACE_TYPE_FIELD)
351
629
  ]
352
630
  data = {
353
631
  "id": workspace_id,
354
632
  "name": workspace_name,
633
+ "description": workspace_description,
355
634
  "type": workspace_type,
356
635
  **kwargs, # ← allows adding more attributes from caller
357
636
  }
358
637
  if metadata:
359
- response = self._otcs.get_workspace(node_id=workspace_id, fields="categories", metadata=True)
638
+ response = self._otcs.get_workspace(node_id=int(workspace_id), fields="categories", metadata=True)
360
639
  if response:
361
640
  data["attributes"] = self._otcs.extract_category_data(node=response)
641
+ else:
642
+ self.logger.warning(
643
+ "Workspace -> '%s' (%s) has no metadata! Cannot add it to the graph.",
644
+ workspace_name,
645
+ workspace_id,
646
+ )
362
647
  with self._nodes.lock():
363
648
  self._nodes.append(data)
364
649
  return (True, True)
@@ -407,8 +692,8 @@ class KnowledgeGraph:
407
692
  "target_id": workspace_target_id,
408
693
  "relationship_type": rel_type,
409
694
  "relationship_semantics": self.get_semantic_labels(
410
- source_type=self.workspace_types.get(workspace_source_type, workspace_source_type),
411
- target_type=self.workspace_types.get(workspace_target_type, workspace_target_type),
695
+ source_type=self._workspace_types.get(workspace_source_type, workspace_source_type),
696
+ target_type=self._workspace_types.get(workspace_target_type, workspace_target_type),
412
697
  rel_type=rel_type,
413
698
  ),
414
699
  **kwargs, # ← allows adding more attributes from caller
@@ -433,6 +718,9 @@ class KnowledgeGraph:
433
718
  metadata=metadata,
434
719
  )
435
720
 
721
+ # mark the graph as ready:
722
+ self._graph_ready = True
723
+
436
724
  return result
437
725
 
438
726
  # end method definition
@@ -474,7 +762,7 @@ class KnowledgeGraph:
474
762
  intermediate_types: list[str] | None = None,
475
763
  strict_intermediate_types: bool = False,
476
764
  ordered_intermediate_types: bool = False,
477
- ) -> set[tuple[str, int]]:
765
+ ) -> dict:
478
766
  """Find target entities by using the Knowledge Graph.
479
767
 
480
768
  Given a source entity (like Material:M-789), find target entities (like Customer)
@@ -488,7 +776,7 @@ class KnowledgeGraph:
488
776
  target_type (str):
489
777
  Desired result type (e.g. "Customer").
490
778
  target_value (str | int | None, optional):
491
- The value or name of the source node (e.g. "M-789").
779
+ The value or name of the target node (e.g. "Global Trade AG").
492
780
  max_hops (int | None, optional):
493
781
  Limit on graph traversal depth. If None (default) there's no limit.
494
782
  direction (str, optional):
@@ -502,8 +790,55 @@ class KnowledgeGraph:
502
790
  Enforce order of intermediate types.
503
791
 
504
792
  Returns:
505
- set:
506
- Set of (name, id) tuples for matching nodes, e.g. {("Customer A", 123)}
793
+ dict:
794
+ Dictionary with node ID as key and values consisting of "name" and
795
+ optional "attributes" keys. "attributes" are just provided if the graph
796
+ was built with "metadata=True".
797
+
798
+ Example:
799
+ {
800
+ 29023: {
801
+ 'name': 'C.E.B. Berlin SE (10020)',
802
+ 'attributes': {
803
+ 'Vendor': {
804
+ 'Locations': [
805
+ {
806
+ 'Type': 'Stand. Address',
807
+ 'Street': 'Potsdamer Platz 1',
808
+ 'City': 'Berlin',
809
+ 'Country': 'Germany',
810
+ 'Postal code': '10785',
811
+ 'Valid from': '2016-01-31T11:00:00',
812
+ 'Valid to': '9999-12-31T11:00:00'
813
+ }
814
+ ],
815
+ 'Contacts': [
816
+ {
817
+ 'BP No': '0000001101',
818
+ 'Name': 'Matthias Schwarz',
819
+ 'Department': '',
820
+ 'Function': '',
821
+ 'Phone': '',
822
+ ...
823
+ }
824
+ ],
825
+ 'Name': 'C.E.B. Berlin SE',
826
+ 'Street': 'Potsdamer Platz 1',
827
+ 'Bank Accounts': [...],
828
+ 'City': 'Berlin',
829
+ 'Postal Code': '10785',
830
+ 'Country': 'Germany',
831
+ 'Purchasing Organization': ['1000 Innovate Germany'],
832
+ 'Number': '10020',
833
+ 'Key': '0000010020'
834
+ }
835
+ }
836
+ },
837
+ 29140: {
838
+ 'name': 'C.E.B. New York Inc. (30010)',
839
+ 'attributes': {...}
840
+ },
841
+ ...
507
842
 
508
843
  """
509
844
 
@@ -522,7 +857,7 @@ class KnowledgeGraph:
522
857
  & (nodes_df["name"].astype(str).str.contains(str(source_value), case=False, na=False, regex=False))
523
858
  ]
524
859
  if source_node.empty:
525
- return set()
860
+ return {}
526
861
  start_id = source_node.iloc[0]["id"]
527
862
 
528
863
  #
@@ -530,7 +865,7 @@ class KnowledgeGraph:
530
865
  #
531
866
  visited = set()
532
867
  queue = deque([(start_id, 0, [])]) # (node_id, depth, path_types)
533
- results = set()
868
+ results: dict[int, dict[str, object]] = {}
534
869
  # Cache of build_type_pathes results
535
870
  type_path_cache = {}
536
871
 
@@ -555,6 +890,7 @@ class KnowledgeGraph:
555
890
 
556
891
  current_type = node_row.iloc[0]["type"]
557
892
  current_name = node_row.iloc[0]["name"]
893
+ current_attributes = node_row.iloc[0].get("attributes", {})
558
894
 
559
895
  # Check if this node has been traversed before. If yes, skip.
560
896
  if current_id in visited:
@@ -626,7 +962,7 @@ class KnowledgeGraph:
626
962
  if (
627
963
  target_value is not None
628
964
  and target_value != current_name # exact match
629
- and target_value.lower() not in current_name.lower() # partial match (substring)
965
+ and str(target_value).lower() not in current_name.lower() # partial match (substring)
630
966
  ):
631
967
  self.logger.debug(
632
968
  "Target node -> '%s' (%d) has the right type -> '%s' but not matching name or attributes (%s)",
@@ -637,7 +973,13 @@ class KnowledgeGraph:
637
973
  )
638
974
  continue
639
975
 
640
- results.add((current_name, current_id))
976
+ results[current_id] = {
977
+ "id": current_id,
978
+ "name": current_name,
979
+ "type": target_type,
980
+ "attributes": current_attributes,
981
+ "path": path_types,
982
+ }
641
983
  self.logger.debug(
642
984
  "Found node -> '%s' (%d) of desired target type -> '%s'%s%s",
643
985
  current_name,
@@ -702,14 +1044,15 @@ class KnowledgeGraph:
702
1044
  # Get neighbor nodes with their types
703
1045
  neighbor_rows = nodes_df[nodes_df["id"].isin(neighbor_ids)][["id", "type"]]
704
1046
 
705
- # Filter neighbors by allowed types
1047
+ # Filter neighbors by allowed types and not visited before:
706
1048
  filtered_neighbors = [
707
1049
  nid
708
1050
  for nid in neighbor_rows.itertuples(index=False)
709
1051
  if nid.type in allowed_types and nid.id not in visited
710
1052
  ]
711
1053
 
712
- # Travers edges from current node to neighbors:
1054
+ # Travers edges from current node to neighbors and
1055
+ # add the neighbors to the processing queue for traversal:
713
1056
  for neighbor in filtered_neighbors:
714
1057
  queue.append((neighbor.id, current_depth + 1, path_types))
715
1058
  # end while queue
@@ -717,3 +1060,138 @@ class KnowledgeGraph:
717
1060
  return results
718
1061
 
719
1062
  # end method definition
1063
+
1064
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="build_attribute")
1065
+ def build_attributes(self, node: dict, **kwargs: dict) -> tuple[bool, bool]: # noqa: ARG002
1066
+ """Build a dictionary of all attributes in a given category in OTCS.
1067
+
1068
+ Args:
1069
+ node (dict):
1070
+ The category node to process.
1071
+ path (list[str]):
1072
+ The current path in the category tree.
1073
+ kwargs (dict):
1074
+ Optional additional parameters.
1075
+
1076
+ Returns:
1077
+ bool:
1078
+ Whether or not the operation was successful.
1079
+ bool:
1080
+ Whether or not we require further traversal.
1081
+
1082
+ """
1083
+
1084
+ if not node:
1085
+ return (False, False)
1086
+
1087
+ success = True
1088
+ traverse = True
1089
+
1090
+ node_id = self._otcs.get_result_value(response=node, key="id")
1091
+ node_name = self._otcs.get_result_value(response=node, key="name")
1092
+ node_type = self._otcs.get_result_value(response=node, key="type")
1093
+ if node_type != self._otcs.ITEM_TYPE_CATEGORY:
1094
+ success = False
1095
+ if node_type not in [self._otcs.VOLUME_TYPE_CATEGORIES_VOLUME, self._otcs.ITEM_TYPE_CATEGORY_FOLDER]:
1096
+ traverse = False
1097
+
1098
+ if success:
1099
+ # Initialize the entry on the category level:
1100
+ self._attribute_schemas[node_name] = {"id": node_id, "attributes": {}}
1101
+ # Retrieve the attribute schema for the given category.
1102
+ # We use the Personal Volume as node_id to have a predictable
1103
+ # node ID that is always available:
1104
+ personal_volume = self._otcs.get_volume(volume_type=self._otcs.VOLUME_TYPE_PERSONAL_WORKSPACE)
1105
+ personal_volume_id = self._otcs.get_result_value(response=personal_volume, key="id")
1106
+ attributes = self._otcs.get_node_category_form(
1107
+ node_id=personal_volume_id, category_id=node_id, operation="create"
1108
+ )
1109
+ if not attributes or "forms" not in attributes or not attributes["forms"]:
1110
+ self.logger.error(
1111
+ "Cannot retrieve attribute schema for category -> '%s' (%s)!",
1112
+ node_name,
1113
+ node_id,
1114
+ )
1115
+ return (False, False)
1116
+ attributes = attributes["forms"][0]["schema"]["properties"] if attributes else {}
1117
+
1118
+ for key, value in attributes.items():
1119
+ if not key[0].isdigit() or "title" not in value:
1120
+ continue
1121
+ self._attribute_schemas[node_name]["attributes"][value["title"]] = key
1122
+ # Process sub-attributes in single-row set:
1123
+ if "properties" in value:
1124
+ for sub_key, sub_value in value["properties"].items():
1125
+ if "title" not in sub_value:
1126
+ continue
1127
+ self._attribute_schemas[node_name]["attributes"][value["title"] + ":" + sub_value["title"]] = (
1128
+ sub_key
1129
+ )
1130
+ # Process sub-attributes in multi-row set:
1131
+ if "items" in value and "properties" in value["items"]:
1132
+ for sub_key, sub_value in value["items"]["properties"].items():
1133
+ if "title" not in sub_value:
1134
+ continue
1135
+ self._attribute_schemas[node_name]["attributes"][value["title"] + ":" + sub_value["title"]] = (
1136
+ sub_key
1137
+ )
1138
+
1139
+ return (success, traverse)
1140
+
1141
+ # end method definition
1142
+
1143
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="build_attributes")
1144
+ def build_categories(self) -> dict:
1145
+ """Build a dictionary of all attribute schemas in OTCS.
1146
+
1147
+ This method populates the `self._attribute_schemas` dictionary with
1148
+ attribute schemas for each category found in the OTCS categories volume.
1149
+
1150
+ It allows for fast lookup of IDs of categories and their attributes
1151
+ by their human-readable names that we use in the AttributesModel in
1152
+ the Aviator tools.
1153
+
1154
+ Each dictionary entry has the following format:
1155
+ {
1156
+ 'Category Name': {
1157
+ 'id': <category_id>,
1158
+ 'attributes': {
1159
+ 'Attribute Title': 'attribute_key',
1160
+ ...
1161
+ }
1162
+ },
1163
+ ...
1164
+ }
1165
+
1166
+ Returns:
1167
+ dict | None:
1168
+ The number of processed and traversed nodes. Format:
1169
+ {
1170
+ "processed": int,
1171
+ "traversed": int,
1172
+ }
1173
+
1174
+
1175
+ Example:
1176
+ {
1177
+ 'Customer': {
1178
+ 'id': 20739,
1179
+ 'attributes': {}
1180
+ },
1181
+ ...
1182
+ }
1183
+
1184
+ """
1185
+
1186
+ self.logger.debug("Start building attribute data lookup...")
1187
+
1188
+ result = self._otcs.traverse_node(
1189
+ node=self._otcs.get_volume(volume_type=self._otcs.VOLUME_TYPE_CATEGORIES_VOLUME),
1190
+ executables=[self.build_attributes],
1191
+ )
1192
+
1193
+ self.logger.debug("Attribute data lookup completed.")
1194
+
1195
+ return result
1196
+
1197
+ # end class definition