howler-api 2.13.0.dev329__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 howler-api might be problematic. Click here for more details.

Files changed (200) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +167 -0
  3. howler/actions/add_label.py +111 -0
  4. howler/actions/add_to_bundle.py +159 -0
  5. howler/actions/change_field.py +76 -0
  6. howler/actions/demote.py +160 -0
  7. howler/actions/example_plugin.py +104 -0
  8. howler/actions/prioritization.py +93 -0
  9. howler/actions/promote.py +147 -0
  10. howler/actions/remove_from_bundle.py +133 -0
  11. howler/actions/remove_label.py +111 -0
  12. howler/actions/transition.py +200 -0
  13. howler/api/__init__.py +249 -0
  14. howler/api/base.py +88 -0
  15. howler/api/socket.py +114 -0
  16. howler/api/v1/__init__.py +97 -0
  17. howler/api/v1/action.py +372 -0
  18. howler/api/v1/analytic.py +748 -0
  19. howler/api/v1/auth.py +382 -0
  20. howler/api/v1/borealis.py +101 -0
  21. howler/api/v1/configs.py +55 -0
  22. howler/api/v1/dossier.py +222 -0
  23. howler/api/v1/help.py +28 -0
  24. howler/api/v1/hit.py +1181 -0
  25. howler/api/v1/notebook.py +82 -0
  26. howler/api/v1/overview.py +191 -0
  27. howler/api/v1/search.py +715 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +414 -0
  31. howler/api/v1/utils/__init__.py +0 -0
  32. howler/api/v1/utils/etag.py +84 -0
  33. howler/api/v1/view.py +288 -0
  34. howler/app.py +235 -0
  35. howler/common/README.md +144 -0
  36. howler/common/__init__.py +0 -0
  37. howler/common/classification.py +979 -0
  38. howler/common/classification.yml +107 -0
  39. howler/common/exceptions.py +167 -0
  40. howler/common/hexdump.py +48 -0
  41. howler/common/iprange.py +171 -0
  42. howler/common/loader.py +154 -0
  43. howler/common/logging/__init__.py +241 -0
  44. howler/common/logging/audit.py +138 -0
  45. howler/common/logging/format.py +38 -0
  46. howler/common/net.py +79 -0
  47. howler/common/net_static.py +1494 -0
  48. howler/common/random_user.py +316 -0
  49. howler/common/swagger.py +117 -0
  50. howler/config.py +64 -0
  51. howler/cronjobs/__init__.py +29 -0
  52. howler/cronjobs/retention.py +61 -0
  53. howler/cronjobs/rules.py +274 -0
  54. howler/cronjobs/view_cleanup.py +88 -0
  55. howler/datastore/README.md +112 -0
  56. howler/datastore/__init__.py +0 -0
  57. howler/datastore/bulk.py +72 -0
  58. howler/datastore/collection.py +2327 -0
  59. howler/datastore/constants.py +117 -0
  60. howler/datastore/exceptions.py +41 -0
  61. howler/datastore/howler_store.py +105 -0
  62. howler/datastore/migrations/fix_process.py +41 -0
  63. howler/datastore/operations.py +130 -0
  64. howler/datastore/schemas.py +90 -0
  65. howler/datastore/store.py +231 -0
  66. howler/datastore/support/__init__.py +0 -0
  67. howler/datastore/support/build.py +214 -0
  68. howler/datastore/support/schemas.py +90 -0
  69. howler/datastore/types.py +22 -0
  70. howler/error.py +91 -0
  71. howler/external/__init__.py +0 -0
  72. howler/external/generate_mitre.py +96 -0
  73. howler/external/generate_sigma_rules.py +31 -0
  74. howler/external/generate_tlds.py +47 -0
  75. howler/external/reindex_data.py +46 -0
  76. howler/external/wipe_databases.py +58 -0
  77. howler/gunicorn_config.py +25 -0
  78. howler/healthz.py +47 -0
  79. howler/helper/__init__.py +0 -0
  80. howler/helper/azure.py +50 -0
  81. howler/helper/discover.py +59 -0
  82. howler/helper/hit.py +236 -0
  83. howler/helper/oauth.py +247 -0
  84. howler/helper/search.py +92 -0
  85. howler/helper/workflow.py +110 -0
  86. howler/helper/ws.py +378 -0
  87. howler/odm/README.md +102 -0
  88. howler/odm/__init__.py +1 -0
  89. howler/odm/base.py +1504 -0
  90. howler/odm/charter.txt +146 -0
  91. howler/odm/helper.py +416 -0
  92. howler/odm/howler_enum.py +25 -0
  93. howler/odm/models/__init__.py +0 -0
  94. howler/odm/models/action.py +33 -0
  95. howler/odm/models/analytic.py +90 -0
  96. howler/odm/models/assemblyline.py +48 -0
  97. howler/odm/models/aws.py +23 -0
  98. howler/odm/models/azure.py +16 -0
  99. howler/odm/models/cbs.py +44 -0
  100. howler/odm/models/config.py +558 -0
  101. howler/odm/models/dossier.py +33 -0
  102. howler/odm/models/ecs/__init__.py +0 -0
  103. howler/odm/models/ecs/agent.py +17 -0
  104. howler/odm/models/ecs/autonomous_system.py +16 -0
  105. howler/odm/models/ecs/client.py +149 -0
  106. howler/odm/models/ecs/cloud.py +141 -0
  107. howler/odm/models/ecs/code_signature.py +27 -0
  108. howler/odm/models/ecs/container.py +32 -0
  109. howler/odm/models/ecs/dns.py +62 -0
  110. howler/odm/models/ecs/egress.py +10 -0
  111. howler/odm/models/ecs/elf.py +74 -0
  112. howler/odm/models/ecs/email.py +122 -0
  113. howler/odm/models/ecs/error.py +14 -0
  114. howler/odm/models/ecs/event.py +140 -0
  115. howler/odm/models/ecs/faas.py +24 -0
  116. howler/odm/models/ecs/file.py +84 -0
  117. howler/odm/models/ecs/geo.py +30 -0
  118. howler/odm/models/ecs/group.py +18 -0
  119. howler/odm/models/ecs/hash.py +16 -0
  120. howler/odm/models/ecs/host.py +17 -0
  121. howler/odm/models/ecs/http.py +37 -0
  122. howler/odm/models/ecs/ingress.py +12 -0
  123. howler/odm/models/ecs/interface.py +21 -0
  124. howler/odm/models/ecs/network.py +30 -0
  125. howler/odm/models/ecs/observer.py +45 -0
  126. howler/odm/models/ecs/organization.py +12 -0
  127. howler/odm/models/ecs/os.py +21 -0
  128. howler/odm/models/ecs/pe.py +17 -0
  129. howler/odm/models/ecs/process.py +216 -0
  130. howler/odm/models/ecs/registry.py +26 -0
  131. howler/odm/models/ecs/related.py +45 -0
  132. howler/odm/models/ecs/rule.py +51 -0
  133. howler/odm/models/ecs/server.py +24 -0
  134. howler/odm/models/ecs/threat.py +247 -0
  135. howler/odm/models/ecs/tls.py +58 -0
  136. howler/odm/models/ecs/url.py +51 -0
  137. howler/odm/models/ecs/user.py +57 -0
  138. howler/odm/models/ecs/user_agent.py +20 -0
  139. howler/odm/models/ecs/vulnerability.py +41 -0
  140. howler/odm/models/gcp.py +16 -0
  141. howler/odm/models/hit.py +356 -0
  142. howler/odm/models/howler_data.py +328 -0
  143. howler/odm/models/lead.py +33 -0
  144. howler/odm/models/localized_label.py +13 -0
  145. howler/odm/models/overview.py +16 -0
  146. howler/odm/models/pivot.py +40 -0
  147. howler/odm/models/template.py +24 -0
  148. howler/odm/models/user.py +83 -0
  149. howler/odm/models/view.py +34 -0
  150. howler/odm/random_data.py +888 -0
  151. howler/odm/randomizer.py +606 -0
  152. howler/patched.py +5 -0
  153. howler/plugins/__init__.py +25 -0
  154. howler/plugins/config.py +123 -0
  155. howler/remote/__init__.py +0 -0
  156. howler/remote/datatypes/README.md +355 -0
  157. howler/remote/datatypes/__init__.py +98 -0
  158. howler/remote/datatypes/counters.py +63 -0
  159. howler/remote/datatypes/events.py +66 -0
  160. howler/remote/datatypes/hash.py +206 -0
  161. howler/remote/datatypes/lock.py +42 -0
  162. howler/remote/datatypes/queues/__init__.py +0 -0
  163. howler/remote/datatypes/queues/comms.py +59 -0
  164. howler/remote/datatypes/queues/multi.py +32 -0
  165. howler/remote/datatypes/queues/named.py +93 -0
  166. howler/remote/datatypes/queues/priority.py +215 -0
  167. howler/remote/datatypes/set.py +118 -0
  168. howler/remote/datatypes/user_quota_tracker.py +54 -0
  169. howler/security/__init__.py +253 -0
  170. howler/security/socket.py +108 -0
  171. howler/security/utils.py +185 -0
  172. howler/services/__init__.py +0 -0
  173. howler/services/action_service.py +111 -0
  174. howler/services/analytic_service.py +128 -0
  175. howler/services/auth_service.py +323 -0
  176. howler/services/config_service.py +128 -0
  177. howler/services/dossier_service.py +252 -0
  178. howler/services/event_service.py +93 -0
  179. howler/services/hit_service.py +893 -0
  180. howler/services/jwt_service.py +158 -0
  181. howler/services/lucene_service.py +286 -0
  182. howler/services/notebook_service.py +119 -0
  183. howler/services/overview_service.py +44 -0
  184. howler/services/template_service.py +45 -0
  185. howler/services/user_service.py +330 -0
  186. howler/utils/__init__.py +0 -0
  187. howler/utils/annotations.py +28 -0
  188. howler/utils/chunk.py +38 -0
  189. howler/utils/dict_utils.py +200 -0
  190. howler/utils/isotime.py +17 -0
  191. howler/utils/list_utils.py +11 -0
  192. howler/utils/lucene.py +77 -0
  193. howler/utils/path.py +27 -0
  194. howler/utils/socket_utils.py +61 -0
  195. howler/utils/str_utils.py +256 -0
  196. howler/utils/uid.py +47 -0
  197. howler_api-2.13.0.dev329.dist-info/METADATA +71 -0
  198. howler_api-2.13.0.dev329.dist-info/RECORD +200 -0
  199. howler_api-2.13.0.dev329.dist-info/WHEEL +4 -0
  200. howler_api-2.13.0.dev329.dist-info/entry_points.txt +8 -0
@@ -0,0 +1,979 @@
1
+ import itertools
2
+ import logging
3
+ from copy import copy
4
+ from typing import Any, Dict, KeysView, List, Optional, Set, Tuple, Union, cast
5
+
6
+ from howler.common.exceptions import (
7
+ HowlerKeyError,
8
+ InvalidClassification,
9
+ InvalidDefinition,
10
+ )
11
+ from howler.common.loader import APP_NAME
12
+
13
+ log = logging.getLogger(f"{APP_NAME}.classification")
14
+
15
+
16
+ class Classification(object):
17
+ MIN_LVL = 1
18
+ MAX_LVL = 10000
19
+ NULL_LVL = 0
20
+ INVALID_LVL = 10001
21
+ NULL_CLASSIFICATION = "NULL"
22
+ INVALID_CLASSIFICATION = "INVALID"
23
+
24
+ def __init__(self, classification_definition: Dict):
25
+ """Returns the classification class instantiated with the classification_definition
26
+
27
+ Args:
28
+ classification_definition: The classification definition dictionary,
29
+ see default classification.yml for an example.
30
+ """
31
+ banned_params_keys = [
32
+ "name",
33
+ "short_name",
34
+ "lvl",
35
+ "aliases",
36
+ "auto_select",
37
+ "css",
38
+ "description",
39
+ ]
40
+ self.original_definition = classification_definition
41
+ self.levels_map: dict[str, str] = {}
42
+ self.levels_map_stl = {}
43
+ self.levels_map_lts = {}
44
+ self.levels_styles_map = {}
45
+ self.levels_aliases = {}
46
+ self.access_req_map_lts = {}
47
+ self.access_req_map_stl = {}
48
+ self.access_req_aliases: dict[str, Any] = {}
49
+ self.groups_map_lts = {}
50
+ self.groups_map_stl = {}
51
+ self.groups_aliases: dict[str, Any] = {}
52
+ self.groups_auto_select = []
53
+ self.groups_auto_select_short = []
54
+ self.subgroups_map_lts = {}
55
+ self.subgroups_map_stl = {}
56
+ self.subgroups_aliases: dict[str, Any] = {}
57
+ self.subgroups_auto_select = []
58
+ self.subgroups_auto_select_short = []
59
+ self.params_map = {}
60
+ self.description = {}
61
+ self.invalid_mode = False
62
+ self._classification_cache = set()
63
+ self._classification_cache_short = set()
64
+
65
+ self.enforce = False
66
+ self.dynamic_groups = False
67
+
68
+ # Add Invalid classification
69
+ self.levels_map["INV"] = self.INVALID_LVL
70
+ self.levels_map[str(self.INVALID_LVL)] = "INV"
71
+ self.levels_map_stl["INV"] = self.INVALID_CLASSIFICATION
72
+ self.levels_map_lts[self.INVALID_CLASSIFICATION] = "INV"
73
+
74
+ # Add null classification
75
+ self.levels_map[self.NULL_CLASSIFICATION] = self.NULL_LVL
76
+ self.levels_map[str(self.NULL_LVL)] = self.NULL_CLASSIFICATION
77
+ self.levels_map_stl[self.NULL_CLASSIFICATION] = self.NULL_CLASSIFICATION
78
+ self.levels_map_lts[self.NULL_CLASSIFICATION] = self.NULL_CLASSIFICATION
79
+
80
+ log.debug("Beginning classification parsing")
81
+ try:
82
+ self.enforce = classification_definition.get("enforce", None)
83
+ if self.enforce is None:
84
+ raise HowlerKeyError("Enforce not set!")
85
+
86
+ self.dynamic_groups = classification_definition.get("dynamic_groups", None)
87
+ if self.enforce is None:
88
+ raise HowlerKeyError("Dynamic groups not set!")
89
+
90
+ if self.enforce:
91
+ self._classification_cache = self.list_all_classification_combinations()
92
+ self._classification_cache_short = self.list_all_classification_combinations(long_format=False)
93
+
94
+ if classification_definition.get("levels", None) is None:
95
+ raise HowlerKeyError("No classification levels provided!")
96
+
97
+ for x in classification_definition["levels"]:
98
+ short_name = x["short_name"]
99
+ name = x["name"]
100
+
101
+ if short_name in ["INV", "NULL"] or name in [
102
+ self.INVALID_CLASSIFICATION,
103
+ self.NULL_CLASSIFICATION,
104
+ ]:
105
+ raise InvalidDefinition(
106
+ "You cannot use reserved words NULL, INVALID or INV in your " "classification definition."
107
+ )
108
+
109
+ lvl = int(x["lvl"])
110
+ if lvl > self.MAX_LVL:
111
+ raise InvalidDefinition("Level over maximum classification level of %s." % self.MAX_LVL)
112
+ if lvl < self.MIN_LVL:
113
+ raise InvalidDefinition("Level under minimum classification level of %s." % self.MIN_LVL)
114
+
115
+ self.levels_map[short_name] = lvl
116
+ self.levels_map[str(lvl)] = short_name
117
+ self.levels_map_stl[short_name] = name
118
+ self.levels_map_lts[name] = short_name
119
+ for a in x.get("aliases", []):
120
+ self.levels_aliases[a] = short_name
121
+ self.params_map[short_name] = {k: v for k, v in x.items() if k not in banned_params_keys}
122
+ self.params_map[name] = self.params_map[short_name]
123
+ self.levels_styles_map[short_name] = x.get("css", {"color": "default"})
124
+ self.levels_styles_map[name] = self.levels_styles_map[short_name]
125
+ self.description[short_name] = x.get("description", "N/A")
126
+ self.description[name] = self.description[short_name]
127
+
128
+ if classification_definition.get("required", None) is None:
129
+ log.warning("No required tokens specified in classification definition!")
130
+
131
+ for x in classification_definition.get("required", []):
132
+ short_name = x["short_name"]
133
+ name = x["name"]
134
+ self.access_req_map_lts[name] = short_name
135
+ self.access_req_map_stl[short_name] = name
136
+ for a in x.get("aliases", []):
137
+ self.access_req_aliases[a] = self.access_req_aliases.get(a, []) + [short_name]
138
+ self.params_map[short_name] = {k: v for k, v in x.items() if k not in banned_params_keys}
139
+ self.params_map[name] = self.params_map[short_name]
140
+ self.description[short_name] = x.get("description", "N/A")
141
+ self.description[name] = self.description[short_name]
142
+
143
+ if classification_definition.get("groups", None) is None:
144
+ log.debug("No groups specified in classification definition!")
145
+
146
+ for x in classification_definition.get("groups", []):
147
+ short_name = x["short_name"]
148
+ name = x["name"]
149
+ self.groups_map_lts[name] = short_name
150
+ self.groups_map_stl[short_name] = name
151
+ for a in x.get("aliases", []):
152
+ self.groups_aliases[a] = list(set(self.groups_aliases.get(a, []) + [short_name]))
153
+ solitary_display_name = x.get("solitary_display_name", None)
154
+ if solitary_display_name:
155
+ self.groups_aliases[solitary_display_name] = list(
156
+ set(self.groups_aliases.get(solitary_display_name, []) + [short_name])
157
+ )
158
+ if x.get("auto_select", False):
159
+ self.groups_auto_select.append(name)
160
+ self.groups_auto_select_short.append(short_name)
161
+ self.params_map[short_name] = {k: v for k, v in x.items() if k not in banned_params_keys}
162
+ self.params_map[name] = self.params_map[short_name]
163
+ self.description[short_name] = x.get("description", "N/A")
164
+ self.description[name] = self.description[short_name]
165
+
166
+ if classification_definition.get("subgroups", None) is None:
167
+ log.debug("No subgroups specified in classification definition!")
168
+
169
+ for x in classification_definition.get("subgroups", []):
170
+ short_name = x["short_name"]
171
+ name = x["name"]
172
+ self.subgroups_map_lts[name] = short_name
173
+ self.subgroups_map_stl[short_name] = name
174
+ for a in x.get("aliases", []):
175
+ self.subgroups_aliases[a] = list(set(self.subgroups_aliases.get(a, []) + [short_name]))
176
+ solitary_display_name = x.get("solitary_display_name", None)
177
+ if solitary_display_name:
178
+ self.subgroups_aliases[solitary_display_name] = list(
179
+ set(self.subgroups_aliases.get(solitary_display_name, []) + [short_name])
180
+ )
181
+ if x.get("auto_select", False):
182
+ self.subgroups_auto_select.append(name)
183
+ self.subgroups_auto_select_short.append(short_name)
184
+ self.params_map[short_name] = {k: v for k, v in x.items() if k not in banned_params_keys}
185
+ self.params_map[name] = self.params_map[short_name]
186
+ self.description[short_name] = x.get("description", "N/A")
187
+ self.description[name] = self.description[short_name]
188
+
189
+ if not self.is_valid(classification_definition["unrestricted"]):
190
+ raise InvalidDefinition("Classification definition's unrestricted classification is invalid.")
191
+
192
+ if not self.is_valid(classification_definition["restricted"]):
193
+ raise InvalidDefinition("Classification definition's restricted classification is invalid.")
194
+
195
+ self.UNRESTRICTED = classification_definition["unrestricted"]
196
+ self.RESTRICTED = classification_definition["restricted"]
197
+
198
+ self.UNRESTRICTED = self.normalize_classification(classification_definition["unrestricted"])
199
+ self.RESTRICTED = self.normalize_classification(classification_definition["restricted"])
200
+
201
+ except Exception as e:
202
+ self.UNRESTRICTED = self.NULL_CLASSIFICATION
203
+ self.RESTRICTED = self.INVALID_CLASSIFICATION
204
+
205
+ self.invalid_mode = True
206
+
207
+ log.warning(
208
+ "Invalid classification: %s. Setting classification mode to invalid",
209
+ str(e),
210
+ )
211
+
212
+ ############################
213
+ # Private functions
214
+ ############################
215
+ @staticmethod
216
+ def _build_combinations(items: Set, separator: str = "/", solitary_display: Optional[Dict] = None) -> Set:
217
+ if solitary_display is None:
218
+ solitary_display = {}
219
+
220
+ out = {""}
221
+ for i in items:
222
+ others = [x for x in items if x != i]
223
+ for x in range(len(others) + 1):
224
+ for c in itertools.combinations(others, x):
225
+ value = separator.join(sorted([i] + list(c)))
226
+ out.add(solitary_display.get(value, value))
227
+
228
+ return out
229
+
230
+ @staticmethod
231
+ def _list_items_and_aliases(data: List, long_format: bool = True) -> Set:
232
+ items = set()
233
+ for item in data:
234
+ if long_format:
235
+ items.add(item["name"])
236
+ else:
237
+ items.add(item["short_name"])
238
+
239
+ return items
240
+
241
+ def _get_c12n_level_index(self, c12n: str) -> str:
242
+ # Parse classifications in uppercase mode only
243
+ c12n = c12n.upper()
244
+
245
+ lvl = c12n.split("//")[0]
246
+ if lvl in self.levels_map:
247
+ return self.levels_map[lvl]
248
+ elif lvl in self.levels_map_lts:
249
+ return self.levels_map[self.levels_map_lts[lvl]]
250
+ elif lvl in self.levels_aliases:
251
+ return self.levels_map[self.levels_aliases[lvl]]
252
+ else:
253
+ raise InvalidClassification(
254
+ "Classification level '%s' was not found in " "your classification definition." % lvl
255
+ )
256
+
257
+ def _get_c12n_level_text(self, lvl_idx: int, long_format: bool = True) -> str:
258
+ text = self.levels_map.get(str(lvl_idx), None)
259
+ if not text:
260
+ raise InvalidClassification(
261
+ "Classification level number '%s' was not " "found in your classification definition." % lvl_idx
262
+ )
263
+ if long_format:
264
+ return self.levels_map_stl[text]
265
+ return text
266
+
267
+ def _get_c12n_required(self, c12n: str, long_format: bool = True) -> List:
268
+ # Parse classifications in uppercase mode only
269
+ c12n = c12n.upper()
270
+
271
+ return_set = set()
272
+ part_set = set(c12n.split("/"))
273
+
274
+ for p in part_set:
275
+ if p in self.access_req_map_lts:
276
+ return_set.add(self.access_req_map_lts[p])
277
+ elif p in self.access_req_map_stl:
278
+ return_set.add(p)
279
+ elif p in self.access_req_aliases:
280
+ for a in self.access_req_aliases[p]:
281
+ return_set.add(a)
282
+
283
+ if long_format:
284
+ return sorted([self.access_req_map_stl[r] for r in return_set])
285
+ return sorted(list(return_set))
286
+
287
+ def _get_c12n_groups(self, c12n: str, long_format: bool = True) -> Tuple[List, List]:
288
+ # Parse classifications in uppercase mode only
289
+ c12n = c12n.upper()
290
+
291
+ g1_set = set()
292
+ g2_set = set()
293
+ others = set()
294
+
295
+ grp_part = c12n.split("//")
296
+ groups = []
297
+ for gp in grp_part:
298
+ gp = gp.replace("REL TO ", "")
299
+ gp = gp.replace("REL ", "")
300
+ temp_group = set([x.strip() for x in gp.split(",")])
301
+ for t in temp_group:
302
+ groups.extend(t.split("/"))
303
+
304
+ for g in groups:
305
+ if g in self.groups_map_lts:
306
+ g1_set.add(self.groups_map_lts[g])
307
+ elif g in self.groups_map_stl:
308
+ g1_set.add(g)
309
+ elif g in self.groups_aliases:
310
+ for a in self.groups_aliases[g]:
311
+ g1_set.add(a)
312
+ elif g in self.subgroups_map_lts:
313
+ g2_set.add(self.subgroups_map_lts[g])
314
+ elif g in self.subgroups_map_stl:
315
+ g2_set.add(g)
316
+ elif g in self.subgroups_aliases:
317
+ for a in self.subgroups_aliases[g]:
318
+ g2_set.add(a)
319
+ else:
320
+ others.add(g)
321
+
322
+ if self.dynamic_groups:
323
+ for o in others:
324
+ if (
325
+ o not in self.access_req_map_lts
326
+ and o not in self.access_req_map_stl
327
+ and o not in self.access_req_aliases
328
+ and o not in self.levels_map
329
+ and o not in self.levels_map_lts
330
+ and o not in self.levels_aliases
331
+ ):
332
+ g1_set.add(o)
333
+
334
+ if long_format:
335
+ return sorted([self.groups_map_stl.get(r, r) for r in g1_set]), sorted(
336
+ [self.subgroups_map_stl[r] for r in g2_set]
337
+ )
338
+ return sorted(list(g1_set)), sorted(list(g2_set))
339
+
340
+ @staticmethod
341
+ def _can_see_required(user_req: List, req: List) -> bool:
342
+ return set(req).issubset(user_req)
343
+
344
+ @staticmethod
345
+ def _can_see_groups(user_groups: List, req: List) -> bool:
346
+ if len(req) == 0:
347
+ return True
348
+
349
+ for g in user_groups:
350
+ if g in req:
351
+ return True
352
+
353
+ return False
354
+
355
+ # noinspection PyTypeChecker
356
+ def _get_normalized_classification_text(
357
+ self,
358
+ lvl_idx: int,
359
+ req: List,
360
+ groups: List,
361
+ subgroups: List,
362
+ long_format: bool = True,
363
+ skip_auto_select: bool = False,
364
+ ) -> str:
365
+ # 1. Check for all required items if they need a specific classification lvl
366
+ required_lvl_idx = 0
367
+ for r in req:
368
+ required_lvl_idx = max(required_lvl_idx, self.params_map.get(r, {}).get("require_lvl", 0))
369
+ out = self._get_c12n_level_text(max(lvl_idx, required_lvl_idx), long_format=long_format)
370
+
371
+ # 2. Check for all required items if they should be shown inside the groups display part
372
+ req_grp = []
373
+ for r in req:
374
+ if self.params_map.get(r, {}).get("is_required_group"):
375
+ req_grp.append(r)
376
+ req = list(set(req).difference(set(req_grp)))
377
+
378
+ if req:
379
+ out += "//" + "/".join(req)
380
+ if req_grp:
381
+ out += "//" + "/".join(sorted(req_grp))
382
+
383
+ # 3. Add auto-selected subgroups
384
+ if long_format:
385
+ if len(subgroups) > 0 and len(self.subgroups_auto_select) > 0 and not skip_auto_select:
386
+ subgroups = sorted(list(set(subgroups).union(set(self.subgroups_auto_select))))
387
+ else:
388
+ if len(subgroups) > 0 and len(self.subgroups_auto_select_short) > 0 and not skip_auto_select:
389
+ subgroups = sorted(list(set(subgroups).union(set(self.subgroups_auto_select_short))))
390
+
391
+ # 4. For every subgroup, check if the subgroup requires or is limited to a specific group
392
+ temp_groups = []
393
+ for sg in subgroups:
394
+ required_group = self.params_map.get(sg, {}).get("require_group", None)
395
+ if required_group is not None:
396
+ temp_groups.append(required_group)
397
+
398
+ limited_to_group = self.params_map.get(sg, {}).get("limited_to_group", None)
399
+ if limited_to_group is not None:
400
+ if limited_to_group in temp_groups:
401
+ temp_groups = [limited_to_group]
402
+ else:
403
+ temp_groups = []
404
+
405
+ for g in temp_groups:
406
+ if long_format:
407
+ groups.append(self.groups_map_stl.get(g, g))
408
+ else:
409
+ groups.append(self.groups_map_lts.get(g, g))
410
+ groups = list(set(groups))
411
+
412
+ # 5. Add auto-selected groups
413
+ if long_format:
414
+ if len(groups) > 0 and len(self.groups_auto_select) > 0 and not skip_auto_select:
415
+ groups = sorted(list(set(groups).union(set(self.groups_auto_select))))
416
+ else:
417
+ if len(groups) > 0 and len(self.groups_auto_select_short) > 0 and not skip_auto_select:
418
+ groups = sorted(list(set(groups).union(set(self.groups_auto_select_short))))
419
+
420
+ if groups:
421
+ out += {True: "/", False: "//"}[len(req_grp) > 0]
422
+ if len(groups) == 1:
423
+ # 6. If only one group, check if it has a solitary display name.
424
+ grp = groups[0]
425
+ display_name = self.params_map.get(grp, {}).get("solitary_display_name", grp)
426
+ if display_name != grp:
427
+ out += display_name
428
+ else:
429
+ out += "REL TO " + grp
430
+ else:
431
+ if not long_format:
432
+ # 7. In short format mode, check if there is an alias that can replace multiple groups
433
+ for alias, values in self.groups_aliases.items():
434
+ if len(values) > 1:
435
+ if sorted(values) == groups:
436
+ groups = [alias]
437
+ out += "REL TO " + ", ".join(sorted(groups))
438
+
439
+ if subgroups:
440
+ if len(groups) > 0 or len(req_grp) > 0:
441
+ out += "/"
442
+ else:
443
+ out += "//"
444
+ out += "/".join(sorted(subgroups))
445
+
446
+ return out
447
+
448
+ def _get_classification_parts(
449
+ self, c12n: str, long_format: bool = True
450
+ ) -> Tuple[Union[Union[int, str], Any], List, List, List]:
451
+ lvl_idx = self._get_c12n_level_index(c12n)
452
+ req = self._get_c12n_required(c12n, long_format=long_format)
453
+ groups, subgroups = self._get_c12n_groups(c12n, long_format=long_format)
454
+
455
+ return lvl_idx, req, groups, subgroups
456
+
457
+ @staticmethod
458
+ def _max_groups(groups_1: List, groups_2: List) -> List:
459
+ if len(groups_1) > 0 and len(groups_2) > 0:
460
+ groups = set(groups_1) & set(groups_2)
461
+ else:
462
+ groups = set(groups_1) | set(groups_2)
463
+
464
+ if len(groups_1) > 0 and len(groups_2) > 0 and len(groups) == 0:
465
+ # NOTE: Intersection generated nothing, we will raise an InvalidClassification exception
466
+ raise InvalidClassification(
467
+ "Could not find any intersection between the groups. %s & %s" % (groups_1, groups_2)
468
+ )
469
+
470
+ return list(groups)
471
+
472
+ # ++++++++++++++++++++++++
473
+ # Public functions
474
+ # ++++++++++++++++++++++++
475
+ # noinspection PyUnusedLocal
476
+ def list_all_classification_combinations(self, long_format: bool = True) -> Set:
477
+ combinations = set()
478
+
479
+ levels = self._list_items_and_aliases(self.original_definition["levels"], long_format=long_format)
480
+ reqs = self._list_items_and_aliases(self.original_definition["required"], long_format=long_format)
481
+ grps = self._list_items_and_aliases(self.original_definition["groups"], long_format=long_format)
482
+ sgrps = self._list_items_and_aliases(self.original_definition["subgroups"], long_format=long_format)
483
+
484
+ req_cbs = self._build_combinations(reqs)
485
+ if long_format:
486
+ grp_solitary_display = {
487
+ x["name"]: x["solitary_display_name"]
488
+ for x in self.original_definition["groups"]
489
+ if "solitary_display_name" in x
490
+ }
491
+ else:
492
+ grp_solitary_display = {
493
+ x["short_name"]: x["solitary_display_name"]
494
+ for x in self.original_definition["groups"]
495
+ if "solitary_display_name" in x
496
+ }
497
+ solitary_names = [
498
+ x["solitary_display_name"] for x in self.original_definition["groups"] if "solitary_display_name" in x
499
+ ]
500
+
501
+ grp_cbs = self._build_combinations(grps, separator=", ", solitary_display=grp_solitary_display)
502
+ sgrp_cbs = self._build_combinations(sgrps)
503
+
504
+ for p in itertools.product(levels, req_cbs):
505
+ cl = "//".join(p)
506
+ if cl.endswith("//"):
507
+ combinations.add(cl[:-2])
508
+ else:
509
+ combinations.add(cl)
510
+
511
+ temp_combinations = copy(combinations)
512
+ for p in itertools.product(temp_combinations, grp_cbs):
513
+ cl = "//REL TO ".join(p)
514
+ if cl.endswith("//REL TO "):
515
+ combinations.add(cl[:-9])
516
+ else:
517
+ combinations.add(cl)
518
+
519
+ for sol_name in solitary_names:
520
+ to_edit = []
521
+ to_find = "REL TO {sol_name}".format(sol_name=sol_name)
522
+ for c in combinations:
523
+ if to_find in c:
524
+ to_edit.append(c)
525
+
526
+ for e in to_edit:
527
+ combinations.add(e.replace(to_find, sol_name))
528
+ combinations.remove(e)
529
+
530
+ temp_combinations = copy(combinations)
531
+ for p in itertools.product(temp_combinations, sgrp_cbs):
532
+ if "//REL TO " in p[0]:
533
+ cl = "/".join(p)
534
+
535
+ if cl.endswith("/"):
536
+ combinations.add(cl[:-1])
537
+ else:
538
+ combinations.add(cl)
539
+ else:
540
+ cl = "//REL TO ".join(p)
541
+
542
+ if cl.endswith("//REL TO "):
543
+ combinations.add(cl[:-9])
544
+ else:
545
+ combinations.add(cl)
546
+
547
+ return combinations
548
+
549
+ # noinspection PyUnusedLocal
550
+ def default_user_classification(self, user: Optional[str] = None, long_format: bool = True) -> str:
551
+ """You can overload this function to specify a way to get the default classification of a user.
552
+ By default, this function returns the UNRESTRICTED value of your classification definition.
553
+
554
+ Args:
555
+ user: Which user to get the classification for
556
+ long_format: Request a long classification format or not
557
+
558
+ Returns:
559
+ The classification in the specified format
560
+ """
561
+ return self.UNRESTRICTED
562
+
563
+ def get_parsed_classification_definition(self) -> Dict:
564
+ """Returns all dictionary of all the variables inside the classification object that will be used
565
+ to enforce classification throughout the system.
566
+ """
567
+ from copy import deepcopy
568
+
569
+ out = deepcopy(self.__dict__)
570
+ out["levels_map"].pop("INV", None)
571
+ out["levels_map"].pop(str(self.INVALID_LVL), None)
572
+ out["levels_map_stl"].pop("INV", None)
573
+ out["levels_map_lts"].pop("INVALID", None)
574
+ out["levels_map"].pop("NULL", None)
575
+ out["levels_map"].pop(str(self.NULL_LVL), None)
576
+ out["levels_map_stl"].pop("NULL", None)
577
+ out["levels_map_lts"].pop("NULL", None)
578
+ out.pop("_classification_cache", None)
579
+ out.pop("_classification_cache_short", None)
580
+ out.pop("original_definition", None)
581
+ return out
582
+
583
+ def get_access_control_parts(self, c12n: str, user_classification: bool = False) -> Dict:
584
+ """Returns a dictionary containing the different access parameters Lucene needs to build it's queries
585
+
586
+ Args:
587
+ c12n: The classification to get the parts from
588
+ user_classification: Is a user classification
589
+ """
590
+ if not self.enforce or self.invalid_mode:
591
+ c12n = self.UNRESTRICTED
592
+
593
+ try:
594
+ # Normalize the classification before gathering the parts
595
+ c12n = self.normalize_classification(c12n, skip_auto_select=user_classification)
596
+
597
+ access_lvl = self._get_c12n_level_index(c12n)
598
+ access_req = self._get_c12n_required(c12n, long_format=False)
599
+ access_grp1, access_grp2 = self._get_c12n_groups(c12n, long_format=False)
600
+
601
+ return {
602
+ "__access_lvl__": access_lvl,
603
+ "__access_req__": access_req,
604
+ "__access_grp1__": access_grp1 or ["__EMPTY__"],
605
+ "__access_grp2__": access_grp2 or ["__EMPTY__"],
606
+ }
607
+ except InvalidClassification:
608
+ if not self.enforce or self.invalid_mode:
609
+ return {
610
+ "__access_lvl__": self.NULL_LVL,
611
+ "__access_req__": [],
612
+ "__access_grp1__": ["__EMPTY__"],
613
+ "__access_grp2__": ["__EMPTY__"],
614
+ }
615
+ else:
616
+ raise
617
+
618
+ def get_access_control_req(self) -> Union[KeysView, List]:
619
+ """Returns a list of the different possible REQUIRED parts"""
620
+ if not self.enforce or self.invalid_mode:
621
+ return []
622
+
623
+ return self.access_req_map_stl.keys()
624
+
625
+ def get_access_control_groups(self) -> Union[KeysView, List]:
626
+ """Returns a list of the different possible GROUPS"""
627
+ if not self.enforce or self.invalid_mode:
628
+ return []
629
+
630
+ return self.groups_map_stl.keys()
631
+
632
+ def get_access_control_subgroups(self) -> Union[KeysView, List]:
633
+ """Returns a list of the different possible SUBGROUPS"""
634
+ if not self.enforce or self.invalid_mode:
635
+ return []
636
+
637
+ return self.subgroups_map_stl.keys()
638
+
639
+ def intersect_user_classification(self, user_c12n_1: str, user_c12n_2: str, long_format: bool = True) -> str:
640
+ """This function intersects two user classification to return the maximum classification
641
+ that both user could see.
642
+
643
+ Args:
644
+ user_c12n_1: First user classification
645
+ user_c12n_2: Second user classification
646
+ long_format: True/False in long format
647
+
648
+ Returns:
649
+ Intersected classification in the desired format
650
+ """
651
+ if not self.enforce or self.invalid_mode:
652
+ return self.UNRESTRICTED
653
+
654
+ # Normalize classifications before comparing them
655
+ if user_c12n_1 is not None:
656
+ user_c12n_1 = self.normalize_classification(user_c12n_1, skip_auto_select=True)
657
+ if user_c12n_2 is not None:
658
+ user_c12n_2 = self.normalize_classification(user_c12n_2, skip_auto_select=True)
659
+
660
+ if user_c12n_1 is None:
661
+ return user_c12n_2
662
+ if user_c12n_2 is None:
663
+ return user_c12n_1
664
+
665
+ lvl_idx_1, req_1, groups_1, subgroups_1 = self._get_classification_parts(user_c12n_1, long_format=long_format)
666
+ lvl_idx_2, req_2, groups_2, subgroups_2 = self._get_classification_parts(user_c12n_2, long_format=long_format)
667
+
668
+ req = list(set(req_1) & set(req_2))
669
+ groups = list(set(groups_1) & set(groups_2))
670
+ subgroups = list(set(subgroups_1) & set(subgroups_2))
671
+
672
+ return self._get_normalized_classification_text(
673
+ min(lvl_idx_1, lvl_idx_2), # type: ignore
674
+ req,
675
+ groups,
676
+ subgroups,
677
+ long_format=long_format,
678
+ skip_auto_select=True,
679
+ )
680
+
681
+ def is_accessible(self, user_c12n: str, c12n: str, ignore_invalid: bool = False) -> bool:
682
+ """Given a user classification, check if a user is allow to see a certain classification
683
+
684
+ Args:
685
+ user_c12n: Maximum classification for the user
686
+ c12n: Classification the user which to see
687
+
688
+ Returns:
689
+ True is the user can see the classification
690
+ """
691
+ if self.invalid_mode:
692
+ return False
693
+
694
+ if not self.enforce:
695
+ return True
696
+
697
+ if c12n is None:
698
+ return True
699
+
700
+ try:
701
+ # Normalize classifications before comparing them
702
+ user_c12n = self.normalize_classification(user_c12n, skip_auto_select=True)
703
+ c12n = self.normalize_classification(c12n, skip_auto_select=True)
704
+
705
+ user_req = self._get_c12n_required(user_c12n)
706
+ user_groups, user_subgroups = self._get_c12n_groups(user_c12n)
707
+ req = self._get_c12n_required(c12n)
708
+ groups, subgroups = self._get_c12n_groups(c12n)
709
+
710
+ if self._get_c12n_level_index(user_c12n) >= self._get_c12n_level_index(c12n):
711
+ if not self._can_see_required(user_req, req):
712
+ return False
713
+ if not self._can_see_groups(user_groups, groups):
714
+ return False
715
+ if not self._can_see_groups(user_subgroups, subgroups):
716
+ return False
717
+ return True
718
+ return False
719
+ except InvalidClassification:
720
+ if ignore_invalid:
721
+ return False
722
+ else:
723
+ raise
724
+
725
+ def is_valid(self, c12n: str, skip_auto_select: bool = False) -> bool:
726
+ """Performs a series of checks againts a classification to make sure it is valid in it's current form
727
+
728
+ Args:
729
+ c12n: The classification we want to validate
730
+ skip_auto_select: skip the auto selection phase
731
+
732
+ Returns:
733
+ True if the classification is valid
734
+ """
735
+ if not self.enforce:
736
+ return True
737
+
738
+ try:
739
+ # Classification normalization test
740
+ n_c12n = self.normalize_classification(c12n, skip_auto_select=skip_auto_select)
741
+ n_lvl_idx, n_req, n_groups, n_subgroups = self._get_classification_parts(n_c12n)
742
+ lvl_idx, req, groups, subgroups = self._get_classification_parts(c12n)
743
+ except InvalidClassification:
744
+ return False
745
+
746
+ if lvl_idx != n_lvl_idx:
747
+ return False
748
+
749
+ if sorted(req) != sorted(n_req):
750
+ return False
751
+
752
+ if sorted(groups) != sorted(n_groups):
753
+ return False
754
+
755
+ if sorted(subgroups) != sorted(n_subgroups):
756
+ return False
757
+
758
+ c12n = c12n.replace("REL TO ", "")
759
+ c12n = c12n.replace("REL ", "")
760
+ parts = c12n.split("//")
761
+
762
+ # There is a maximum of 3 parts
763
+ if len(parts) > 3:
764
+ return False
765
+
766
+ cur_part = parts.pop(0)
767
+ # First parts as to be a classification level part
768
+ if (
769
+ cur_part not in self.levels_aliases.keys()
770
+ and cur_part not in self.levels_map_lts.keys()
771
+ and cur_part not in self.levels_map_stl.keys()
772
+ ):
773
+ return False
774
+
775
+ check_groups = False
776
+ while len(parts) > 0:
777
+ # Can't be two groups sections.
778
+ if check_groups:
779
+ return False
780
+
781
+ cur_part = parts.pop(0)
782
+ items = cur_part.split("/")
783
+ comma_idx = None
784
+ for idx, i in enumerate(items):
785
+ if "," in i:
786
+ comma_idx = idx
787
+
788
+ if comma_idx is not None:
789
+ items += [x.strip() for x in items.pop(comma_idx).split(",")]
790
+
791
+ for i in items:
792
+ if not check_groups:
793
+ # If current item not found in access req, we might already be dealing with groups
794
+ if (
795
+ i not in self.access_req_aliases.keys()
796
+ and i not in self.access_req_map_stl.keys()
797
+ and i not in self.access_req_map_lts.keys()
798
+ ):
799
+ check_groups = True
800
+
801
+ if check_groups and not self.dynamic_groups:
802
+ # If not groups. That stuff does not exists...
803
+ if (
804
+ i not in self.groups_aliases.keys()
805
+ and i not in self.groups_map_stl.keys()
806
+ and i not in self.groups_map_lts.keys()
807
+ and i not in self.subgroups_aliases.keys()
808
+ and i not in self.subgroups_map_stl.keys()
809
+ and i not in self.subgroups_map_lts.keys()
810
+ ):
811
+ return False
812
+
813
+ return True
814
+
815
+ def max_classification(self, c12n_1: str, c12n_2: str, long_format: bool = True) -> str:
816
+ """Mixes to classification and returns to most restrictive form for them
817
+
818
+ Args:
819
+ c12n_1: First classification
820
+ c12n_2: Second classification
821
+ long_format: True/False in long format
822
+
823
+ Returns:
824
+ The most restrictive classification that we could create out of the two
825
+ """
826
+ if not self.enforce or self.invalid_mode:
827
+ return self.UNRESTRICTED
828
+
829
+ # Normalize classifications before comparing them
830
+ if c12n_1 is not None:
831
+ c12n_1 = self.normalize_classification(c12n_1)
832
+ if c12n_2 is not None:
833
+ c12n_2 = self.normalize_classification(c12n_2)
834
+
835
+ if c12n_1 is None:
836
+ return c12n_2
837
+ if c12n_2 is None:
838
+ return c12n_1
839
+
840
+ lvl_idx_1, req_1, groups_1, subgroups_1 = self._get_classification_parts(c12n_1, long_format=long_format)
841
+ lvl_idx_2, req_2, groups_2, subgroups_2 = self._get_classification_parts(c12n_2, long_format=long_format)
842
+
843
+ req = list(set(req_1) | set(req_2))
844
+ groups = self._max_groups(groups_1, groups_2)
845
+ subgroups = self._max_groups(subgroups_1, subgroups_2)
846
+
847
+ return self._get_normalized_classification_text(
848
+ cast(int, max(lvl_idx_1, lvl_idx_2)),
849
+ req,
850
+ groups,
851
+ subgroups,
852
+ long_format=long_format, # type: ignore
853
+ )
854
+
855
+ def min_classification(self, c12n_1: str, c12n_2: str, long_format: bool = True) -> str:
856
+ """Mixes to classification and returns to least restrictive form for them
857
+
858
+ Args:
859
+ c12n_1: First classification
860
+ c12n_2: Second classification
861
+ long_format: True/False in long format
862
+
863
+ Returns:
864
+ The least restrictive classification that we could create out of the two
865
+ """
866
+ if not self.enforce or self.invalid_mode:
867
+ return self.UNRESTRICTED
868
+
869
+ # Normalize classifications before comparing them
870
+ if c12n_1 is not None:
871
+ c12n_1 = self.normalize_classification(c12n_1)
872
+ if c12n_2 is not None:
873
+ c12n_2 = self.normalize_classification(c12n_2)
874
+
875
+ if c12n_1 is None:
876
+ return c12n_2
877
+ if c12n_2 is None:
878
+ return c12n_1
879
+
880
+ lvl_idx_1, req_1, groups_1, subgroups_1 = self._get_classification_parts(c12n_1, long_format=long_format)
881
+ lvl_idx_2, req_2, groups_2, subgroups_2 = self._get_classification_parts(c12n_2, long_format=long_format)
882
+
883
+ req = list(set(req_1) & set(req_2))
884
+ if len(groups_1) > 0 and len(groups_2) > 0:
885
+ groups = list(set(groups_1) | set(groups_2))
886
+ else:
887
+ groups = []
888
+
889
+ if len(subgroups_1) > 0 and len(subgroups_2) > 0:
890
+ subgroups = list(set(subgroups_1) | set(subgroups_2))
891
+ else:
892
+ subgroups = []
893
+
894
+ return self._get_normalized_classification_text(
895
+ cast(int, min(lvl_idx_1, lvl_idx_2)),
896
+ req,
897
+ groups,
898
+ subgroups,
899
+ long_format=long_format, # type: ignore
900
+ )
901
+
902
+ def normalize_classification(self, c12n: str, long_format: bool = True, skip_auto_select: bool = False) -> str:
903
+ """Normalize a given classification by applying the rules defined in the classification definition.
904
+ This function will remove any invalid parts and add missing parts to the classification.
905
+ It will also ensure that the display of the classification is always done the same way
906
+
907
+ Args:
908
+ c12n: Classification to normalize
909
+ long_format: True/False in long format
910
+ skip_auto_select: True/False skip group auto adding, use True when dealing with user's classifications
911
+
912
+ Returns:
913
+ A normalized version of the original classification
914
+ """
915
+ if not self.enforce or self.invalid_mode:
916
+ return self.UNRESTRICTED
917
+
918
+ # Has the classification has already been normalized before?
919
+ if long_format and c12n in self._classification_cache:
920
+ return c12n
921
+ if not long_format and c12n in self._classification_cache_short:
922
+ return c12n
923
+
924
+ lvl_idx, req, groups, subgroups = self._get_classification_parts(c12n, long_format=long_format)
925
+ new_c12n = self._get_normalized_classification_text(
926
+ lvl_idx, # type: ignore
927
+ req,
928
+ groups,
929
+ subgroups,
930
+ long_format=long_format,
931
+ skip_auto_select=skip_auto_select,
932
+ )
933
+ if long_format:
934
+ self._classification_cache.add(new_c12n)
935
+ else:
936
+ self._classification_cache_short.add(new_c12n)
937
+
938
+ return new_c12n
939
+
940
+ def build_user_classification(self, c12n_1: str, c12n_2: str, long_format: bool = True) -> str:
941
+ """Mixes to classification and return the classification marking that would give access to the most data
942
+
943
+ Args:
944
+ c12n_1: First classification
945
+ c12n_2: Second classification
946
+ long_format: True/False in long format
947
+
948
+ Returns:
949
+ The classification that would give access to the most data
950
+ """
951
+ if not self.enforce or self.invalid_mode:
952
+ return self.UNRESTRICTED
953
+
954
+ # Normalize classifications before comparing them
955
+ if c12n_1 is not None:
956
+ c12n_1 = self.normalize_classification(c12n_1, skip_auto_select=True)
957
+ if c12n_2 is not None:
958
+ c12n_2 = self.normalize_classification(c12n_2, skip_auto_select=True)
959
+
960
+ if c12n_1 is None:
961
+ return c12n_2
962
+ if c12n_2 is None:
963
+ return c12n_1
964
+
965
+ lvl_idx_1, req_1, groups_1, subgroups_1 = self._get_classification_parts(c12n_1, long_format=long_format)
966
+ lvl_idx_2, req_2, groups_2, subgroups_2 = self._get_classification_parts(c12n_2, long_format=long_format)
967
+
968
+ req = list(set(req_1) | set(req_2))
969
+ groups = list(set(groups_1) | set(groups_2))
970
+ subgroups = list(set(subgroups_1) | set(subgroups_2))
971
+
972
+ return self._get_normalized_classification_text(
973
+ max(lvl_idx_1, lvl_idx_2), # type: ignore
974
+ req,
975
+ groups,
976
+ subgroups,
977
+ long_format=long_format,
978
+ skip_auto_select=True,
979
+ )