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

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 +870 -67
  9. pyxecm/otcs.py +1567 -280
  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.0.dist-info}/METADATA +2 -1
  15. pyxecm-3.1.0.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 +164 -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 +98 -48
  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 +501 -20
  28. pyxecm_customizer/m365.py +45 -44
  29. pyxecm_customizer/payload.py +1684 -1159
  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.0.dist-info}/WHEEL +0 -0
  52. {pyxecm-3.0.1.dist-info → pyxecm-3.1.0.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,241 @@ 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_attributes()
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 attribute schemas dictionary.
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 schemas dictionary.
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_attributes()
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 now 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
+ return None
324
+
325
+ if lookup_key not in attributes:
326
+ self.logger.error("Attribute '%s' not found in category '%s'!", lookup_key, category.get("id", "unknown"))
327
+ return None
328
+
329
+ key = attributes[lookup_key]
330
+
331
+ if row is not None and "_x_" in key:
332
+ # We have to adjust for 0-based index:
333
+ key = key.replace("_x_", "_{}_".format(row))
334
+ elif row is not None:
335
+ self.logger.warning("Row specified for non-multi-line set attribute - ignoring row.")
336
+
337
+ return key
338
+
339
+ # end method definition
340
+
341
+ def get_result_values(self, response: dict, key: str, sub_keys: list[str] | None = None) -> list | None:
342
+ """Read all values with a given key from the Knowledge Graph Query.
343
+
344
+ Args:
345
+ response (dict):
346
+ Knowledge Graph query result. Example:
347
+ 28038: {
348
+ 'id': '28038',
349
+ 'name': 'C.E.B. Berlin SE (10020)',
350
+ 'attributes': {'Vendor': {...}}
351
+ 'path': ['Material', 'Purchase Order', 'Vendor']
352
+ }
353
+ key (str):
354
+ Key to find (e.g., "name", "attributes").
355
+ sub_keys (list[str] | None, optional):
356
+ Sub keys to lookup data in a dictionary like "attributes".
357
+
358
+ Returns:
359
+ list | None:
360
+ Value list of the item with the given key, or None if no value is found.
361
+
362
+ """
363
+
364
+ # First do some sanity checks:
365
+ if not response:
366
+ self.logger.debug("Empty query response - returning None")
367
+ return None
368
+
369
+ # Initialize results variable:
370
+ results = []
371
+
372
+ # Loop through all graph response values:
373
+ for value in response.values():
374
+ # Check if the key does actually exist in the current value:
375
+ if key not in value:
376
+ continue
377
+
378
+ item = value[key]
379
+
380
+ # If sub-keys exist, loop through them and expand sub-dicts:
381
+ if sub_keys:
382
+ for sub_key in sub_keys:
383
+ if not isinstance(item, dict) or sub_key not in item:
384
+ item = None
385
+ break
386
+ item = item[sub_key]
387
+
388
+ # If a final item is found after expansion add it to the result list:
389
+ if item is not None:
390
+ results.append(item)
391
+
392
+ # We want to return None instead of an empty list:
393
+ return results or None
394
+
395
+ # end method definition
396
+
397
+ def get_result_values_iterator(
398
+ self,
399
+ response: dict,
400
+ ) -> iter:
401
+ """Get an iterator object that can be used to traverse through OTCS responses.
402
+
403
+ Args:
404
+ response (dict):
405
+ REST API response object.
406
+
407
+ Returns:
408
+ list | None:
409
+ Value list of the item with the given key, or None if no value is found.
410
+
411
+ """
412
+
413
+ # First do some sanity checks:
414
+ if not response:
415
+ return
416
+
417
+ yield from response.values()
418
+
419
+ # end method definition
420
+
144
421
  @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="build_type_graph")
145
422
  def build_type_graph(self, directions: list | None = None) -> dict[str, set[str]]:
146
423
  """Construct a directed type-level graph from the ontology.
@@ -346,19 +623,30 @@ class KnowledgeGraph:
346
623
 
347
624
  workspace_id = self._otcs.get_result_value(response=workspace_node, key=self.WORKSPACE_ID_FIELD)
348
625
  workspace_name = self._otcs.get_result_value(response=workspace_node, key=self.WORKSPACE_NAME_FIELD)
349
- workspace_type = self.workspace_types[
626
+ workspace_description = self._otcs.get_result_value(
627
+ response=workspace_node, key=self.WORKSPACE_DESCRIPTION_FIELD
628
+ )
629
+ # We use the cached names of the workspace types:
630
+ workspace_type = self._workspace_types[
350
631
  self._otcs.get_result_value(response=workspace_node, key=self.WORKSPACE_TYPE_FIELD)
351
632
  ]
352
633
  data = {
353
634
  "id": workspace_id,
354
635
  "name": workspace_name,
636
+ "description": workspace_description,
355
637
  "type": workspace_type,
356
638
  **kwargs, # ← allows adding more attributes from caller
357
639
  }
358
640
  if metadata:
359
- response = self._otcs.get_workspace(node_id=workspace_id, fields="categories", metadata=True)
641
+ response = self._otcs.get_workspace(node_id=int(workspace_id), fields="categories", metadata=True)
360
642
  if response:
361
643
  data["attributes"] = self._otcs.extract_category_data(node=response)
644
+ else:
645
+ self.logger.warning(
646
+ "Workspace -> '%s' (%s) has no metadata! Cannot add it to the graph.",
647
+ workspace_name,
648
+ workspace_id,
649
+ )
362
650
  with self._nodes.lock():
363
651
  self._nodes.append(data)
364
652
  return (True, True)
@@ -407,8 +695,8 @@ class KnowledgeGraph:
407
695
  "target_id": workspace_target_id,
408
696
  "relationship_type": rel_type,
409
697
  "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),
698
+ source_type=self._workspace_types.get(workspace_source_type, workspace_source_type),
699
+ target_type=self._workspace_types.get(workspace_target_type, workspace_target_type),
412
700
  rel_type=rel_type,
413
701
  ),
414
702
  **kwargs, # ← allows adding more attributes from caller
@@ -433,6 +721,9 @@ class KnowledgeGraph:
433
721
  metadata=metadata,
434
722
  )
435
723
 
724
+ # mark the graph as ready:
725
+ self._graph_ready = True
726
+
436
727
  return result
437
728
 
438
729
  # end method definition
@@ -474,7 +765,7 @@ class KnowledgeGraph:
474
765
  intermediate_types: list[str] | None = None,
475
766
  strict_intermediate_types: bool = False,
476
767
  ordered_intermediate_types: bool = False,
477
- ) -> set[tuple[str, int]]:
768
+ ) -> dict:
478
769
  """Find target entities by using the Knowledge Graph.
479
770
 
480
771
  Given a source entity (like Material:M-789), find target entities (like Customer)
@@ -488,7 +779,7 @@ class KnowledgeGraph:
488
779
  target_type (str):
489
780
  Desired result type (e.g. "Customer").
490
781
  target_value (str | int | None, optional):
491
- The value or name of the source node (e.g. "M-789").
782
+ The value or name of the target node (e.g. "Global Trade AG").
492
783
  max_hops (int | None, optional):
493
784
  Limit on graph traversal depth. If None (default) there's no limit.
494
785
  direction (str, optional):
@@ -502,8 +793,55 @@ class KnowledgeGraph:
502
793
  Enforce order of intermediate types.
503
794
 
504
795
  Returns:
505
- set:
506
- Set of (name, id) tuples for matching nodes, e.g. {("Customer A", 123)}
796
+ dict:
797
+ Dictionary with node ID as key and values consisting of "name" and
798
+ optional "attributes" keys. "attributes" are just provided if the graph
799
+ was built with "metadata=True".
800
+
801
+ Example:
802
+ {
803
+ 29023: {
804
+ 'name': 'C.E.B. Berlin SE (10020)',
805
+ 'attributes': {
806
+ 'Vendor': {
807
+ 'Locations': [
808
+ {
809
+ 'Type': 'Stand. Address',
810
+ 'Street': 'Potsdamer Platz 1',
811
+ 'City': 'Berlin',
812
+ 'Country': 'Germany',
813
+ 'Postal code': '10785',
814
+ 'Valid from': '2016-01-31T11:00:00',
815
+ 'Valid to': '9999-12-31T11:00:00'
816
+ }
817
+ ],
818
+ 'Contacts': [
819
+ {
820
+ 'BP No': '0000001101',
821
+ 'Name': 'Matthias Schwarz',
822
+ 'Department': '',
823
+ 'Function': '',
824
+ 'Phone': '',
825
+ ...
826
+ }
827
+ ],
828
+ 'Name': 'C.E.B. Berlin SE',
829
+ 'Street': 'Potsdamer Platz 1',
830
+ 'Bank Accounts': [...],
831
+ 'City': 'Berlin',
832
+ 'Postal Code': '10785',
833
+ 'Country': 'Germany',
834
+ 'Purchasing Organization': ['1000 Innovate Germany'],
835
+ 'Number': '10020',
836
+ 'Key': '0000010020'
837
+ }
838
+ }
839
+ },
840
+ 29140: {
841
+ 'name': 'C.E.B. New York Inc. (30010)',
842
+ 'attributes': {...}
843
+ },
844
+ ...
507
845
 
508
846
  """
509
847
 
@@ -522,7 +860,7 @@ class KnowledgeGraph:
522
860
  & (nodes_df["name"].astype(str).str.contains(str(source_value), case=False, na=False, regex=False))
523
861
  ]
524
862
  if source_node.empty:
525
- return set()
863
+ return {}
526
864
  start_id = source_node.iloc[0]["id"]
527
865
 
528
866
  #
@@ -530,7 +868,7 @@ class KnowledgeGraph:
530
868
  #
531
869
  visited = set()
532
870
  queue = deque([(start_id, 0, [])]) # (node_id, depth, path_types)
533
- results = set()
871
+ results: dict[int, dict[str, object]] = {}
534
872
  # Cache of build_type_pathes results
535
873
  type_path_cache = {}
536
874
 
@@ -555,6 +893,7 @@ class KnowledgeGraph:
555
893
 
556
894
  current_type = node_row.iloc[0]["type"]
557
895
  current_name = node_row.iloc[0]["name"]
896
+ current_attributes = node_row.iloc[0].get("attributes", {})
558
897
 
559
898
  # Check if this node has been traversed before. If yes, skip.
560
899
  if current_id in visited:
@@ -626,7 +965,7 @@ class KnowledgeGraph:
626
965
  if (
627
966
  target_value is not None
628
967
  and target_value != current_name # exact match
629
- and target_value.lower() not in current_name.lower() # partial match (substring)
968
+ and str(target_value).lower() not in current_name.lower() # partial match (substring)
630
969
  ):
631
970
  self.logger.debug(
632
971
  "Target node -> '%s' (%d) has the right type -> '%s' but not matching name or attributes (%s)",
@@ -637,7 +976,13 @@ class KnowledgeGraph:
637
976
  )
638
977
  continue
639
978
 
640
- results.add((current_name, current_id))
979
+ results[current_id] = {
980
+ "id": current_id,
981
+ "name": current_name,
982
+ "type": target_type,
983
+ "attributes": current_attributes,
984
+ "path": path_types,
985
+ }
641
986
  self.logger.debug(
642
987
  "Found node -> '%s' (%d) of desired target type -> '%s'%s%s",
643
988
  current_name,
@@ -702,14 +1047,15 @@ class KnowledgeGraph:
702
1047
  # Get neighbor nodes with their types
703
1048
  neighbor_rows = nodes_df[nodes_df["id"].isin(neighbor_ids)][["id", "type"]]
704
1049
 
705
- # Filter neighbors by allowed types
1050
+ # Filter neighbors by allowed types and not visited before:
706
1051
  filtered_neighbors = [
707
1052
  nid
708
1053
  for nid in neighbor_rows.itertuples(index=False)
709
1054
  if nid.type in allowed_types and nid.id not in visited
710
1055
  ]
711
1056
 
712
- # Travers edges from current node to neighbors:
1057
+ # Travers edges from current node to neighbors and
1058
+ # add the neighbors to the processing queue for traversal:
713
1059
  for neighbor in filtered_neighbors:
714
1060
  queue.append((neighbor.id, current_depth + 1, path_types))
715
1061
  # end while queue
@@ -717,3 +1063,138 @@ class KnowledgeGraph:
717
1063
  return results
718
1064
 
719
1065
  # end method definition
1066
+
1067
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="build_attribute")
1068
+ def build_attribute(self, node: dict, **kwargs: dict) -> tuple[bool, bool]: # noqa: ARG002
1069
+ """Build a dictionary of all attribute schemas in OTCS.
1070
+
1071
+ Args:
1072
+ node (dict):
1073
+ The category node to process.
1074
+ path (list[str]):
1075
+ The current path in the category tree.
1076
+ kwargs (dict):
1077
+ Optional additional parameters.
1078
+
1079
+ Returns:
1080
+ bool:
1081
+ Whether or not the operation was successful.
1082
+ bool:
1083
+ Whether or not we require further traversal.
1084
+
1085
+ """
1086
+
1087
+ if not node:
1088
+ return (False, False)
1089
+
1090
+ success = True
1091
+ traverse = True
1092
+
1093
+ node_id = self._otcs.get_result_value(response=node, key="id")
1094
+ node_name = self._otcs.get_result_value(response=node, key="name")
1095
+ node_type = self._otcs.get_result_value(response=node, key="type")
1096
+ if node_type != self._otcs.ITEM_TYPE_CATEGORY:
1097
+ success = False
1098
+ if node_type not in [self._otcs.VOLUME_TYPE_CATEGORIES_VOLUME, self._otcs.ITEM_TYPE_CATEGORY_FOLDER]:
1099
+ traverse = False
1100
+
1101
+ if success:
1102
+ # Initialize the entry on the category level:
1103
+ self._attribute_schemas[node_name] = {"id": node_id, "attributes": {}}
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
+ # Retrieve the attribute schema for the given category.
1107
+ # We use the Enterprise Vault (2000) as node_id to have a predictable
1108
+ # node ID that is always available:
1109
+ attributes = self._otcs.get_node_category_form(
1110
+ node_id=personal_volume_id, category_id=node_id, operation="create"
1111
+ )
1112
+ if not attributes or "forms" not in attributes or not attributes["forms"]:
1113
+ self.logger.error(
1114
+ "Cannot retrieve attribute schema for category -> '%s' (%s)!",
1115
+ node_name,
1116
+ node_id,
1117
+ )
1118
+ return (False, False)
1119
+ attributes = attributes["forms"][0]["schema"]["properties"] if attributes else {}
1120
+
1121
+ for key, value in attributes.items():
1122
+ if not key[0].isdigit() or "title" not in value:
1123
+ continue
1124
+ self._attribute_schemas[node_name]["attributes"][value["title"]] = key
1125
+ # Process sub-attributes in single-row set:
1126
+ if "properties" in value:
1127
+ for sub_key, sub_value in value["properties"].items():
1128
+ if "title" not in sub_value:
1129
+ continue
1130
+ self._attribute_schemas[node_name]["attributes"][value["title"] + ":" + sub_value["title"]] = (
1131
+ sub_key
1132
+ )
1133
+ # Process sub-attributes in multi-row set:
1134
+ if "items" in value and "properties" in value["items"]:
1135
+ for sub_key, sub_value in value["items"]["properties"].items():
1136
+ if "title" not in sub_value:
1137
+ continue
1138
+ self._attribute_schemas[node_name]["attributes"][value["title"] + ":" + sub_value["title"]] = (
1139
+ sub_key
1140
+ )
1141
+
1142
+ return (success, traverse)
1143
+
1144
+ # end method definition
1145
+
1146
+ @tracer.start_as_current_span(attributes=OTEL_TRACING_ATTRIBUTES, name="build_attributes")
1147
+ def build_attributes(self) -> dict:
1148
+ """Build a dictionary of all attribute schemas in OTCS.
1149
+
1150
+ This method populates the `self._attribute_schemas` dictionary with
1151
+ attribute schemas for each category found in the OTCS categories volume.
1152
+
1153
+ It allows for fast lookup of IDs of categories and their attributes
1154
+ by their human-readable names that we use in the AttributesModel in
1155
+ the Aviator tools.
1156
+
1157
+ Each dictionary entry has the following format:
1158
+ {
1159
+ 'Category Name': {
1160
+ 'id': <category_id>,
1161
+ 'attributes': {
1162
+ 'Attribute Title': 'attribute_key',
1163
+ ...
1164
+ }
1165
+ },
1166
+ ...
1167
+ }
1168
+
1169
+ Returns:
1170
+ dict | None:
1171
+ The number of processed and traversed nodes. Format:
1172
+ {
1173
+ "processed": int,
1174
+ "traversed": int,
1175
+ }
1176
+
1177
+
1178
+ Example:
1179
+ {
1180
+ 'Customer': {
1181
+ 'id': 20739,
1182
+ 'attributes': {}
1183
+ },
1184
+ ...
1185
+ }
1186
+
1187
+ """
1188
+
1189
+ self.logger.info("Starting attribute data lookup build...")
1190
+
1191
+ result = self._otcs.traverse_node(
1192
+ node=self._otcs.get_volume(volume_type=self._otcs.VOLUME_TYPE_CATEGORIES_VOLUME),
1193
+ executables=[self.build_attribute],
1194
+ )
1195
+
1196
+ self.logger.info("Attributes data lookup build completed.")
1197
+
1198
+ return result
1199
+
1200
+ # end class definition