midpoint-cli 1.6.0.dev3__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.
- midpoint_cli/__init__.py +234 -0
- midpoint_cli/client/__init__.py +199 -0
- midpoint_cli/client/audit.py +178 -0
- midpoint_cli/client/audit_query.py +247 -0
- midpoint_cli/client/configuration.py +15 -0
- midpoint_cli/client/constants.py +37 -0
- midpoint_cli/client/errors.py +74 -0
- midpoint_cli/client/logging_config.py +122 -0
- midpoint_cli/client/midpoint_client.py +866 -0
- midpoint_cli/client/object_query.py +230 -0
- midpoint_cli/client/objects.py +814 -0
- midpoint_cli/client/observer.py +29 -0
- midpoint_cli/client/patch.py +113 -0
- midpoint_cli/client/progress.py +80 -0
- midpoint_cli/client/rest.py +490 -0
- midpoint_cli/client/safe_xml.py +24 -0
- midpoint_cli/client/server_info.py +130 -0
- midpoint_cli/client/server_version.py +74 -0
- midpoint_cli/client/session.py +142 -0
- midpoint_cli/client/task_definitions.py +110 -0
- midpoint_cli/prompt/__init__.py +196 -0
- midpoint_cli/prompt/audit.py +253 -0
- midpoint_cli/prompt/base.py +218 -0
- midpoint_cli/prompt/complete.py +291 -0
- midpoint_cli/prompt/configuration.py +258 -0
- midpoint_cli/prompt/connector.py +45 -0
- midpoint_cli/prompt/console.py +818 -0
- midpoint_cli/prompt/delete.py +89 -0
- midpoint_cli/prompt/get.py +66 -0
- midpoint_cli/prompt/info.py +53 -0
- midpoint_cli/prompt/log.py +98 -0
- midpoint_cli/prompt/org.py +122 -0
- midpoint_cli/prompt/output.py +33 -0
- midpoint_cli/prompt/put.py +71 -0
- midpoint_cli/prompt/report.py +69 -0
- midpoint_cli/prompt/resource.py +217 -0
- midpoint_cli/prompt/role.py +96 -0
- midpoint_cli/prompt/script.py +154 -0
- midpoint_cli/prompt/search.py +51 -0
- midpoint_cli/prompt/shadow.py +87 -0
- midpoint_cli/prompt/task.py +296 -0
- midpoint_cli/prompt/user.py +406 -0
- midpoint_cli-1.6.0.dev3.dist-info/METADATA +1334 -0
- midpoint_cli-1.6.0.dev3.dist-info/RECORD +47 -0
- midpoint_cli-1.6.0.dev3.dist-info/WHEEL +4 -0
- midpoint_cli-1.6.0.dev3.dist-info/entry_points.txt +3 -0
- midpoint_cli-1.6.0.dev3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import re
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
from xml.etree.ElementTree import Element
|
|
10
|
+
|
|
11
|
+
from rich.console import Group
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from unidecode import unidecode
|
|
15
|
+
|
|
16
|
+
from midpoint_cli.client.constants import AUDIT_NS, COMMON_NS, ResultStatus, TaskExecutionState
|
|
17
|
+
from midpoint_cli.client.server_version import MidpointVersion, task_uses_execution_state
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def safe_get_text(element: Element | None, default: str = '') -> str:
|
|
21
|
+
"""Safely get text from an XML element, returning default if element is None."""
|
|
22
|
+
return element.text if element is not None and element.text is not None else default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def safe_get_int(element: Element | None, default: int = 0) -> int:
|
|
26
|
+
"""Safely get integer from an XML element text, returning default if element is None or text is not a valid int."""
|
|
27
|
+
if element is None or element.text is None:
|
|
28
|
+
return default
|
|
29
|
+
try:
|
|
30
|
+
return int(element.text)
|
|
31
|
+
except (ValueError, TypeError):
|
|
32
|
+
return default
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
namespaces = {
|
|
36
|
+
'c': COMMON_NS,
|
|
37
|
+
'aud': AUDIT_NS,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_T = TypeVar('_T', bound='MidpointObject')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MidpointObject(OrderedDict):
|
|
45
|
+
"""A Midpoint object parsed for display: an ordered mapping of column names to values.
|
|
46
|
+
|
|
47
|
+
Every subclass sets at least ``OID`` and ``Name``; the other keys are the columns of its
|
|
48
|
+
listing, in display order.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def get_oid(self) -> str | None:
|
|
52
|
+
"""The object's OID as a string, or None when the parsed object has none."""
|
|
53
|
+
oid = self.get('OID')
|
|
54
|
+
return str(oid) if oid is not None else None
|
|
55
|
+
|
|
56
|
+
def get_name(self) -> str | None:
|
|
57
|
+
"""The object's name (``c:name``) as a string, or None when it has none."""
|
|
58
|
+
name = self.get('Name')
|
|
59
|
+
return str(name) if name is not None else None
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def error_stub(cls: type[_T], oid: str) -> _T:
|
|
63
|
+
"""A placeholder for an object of this class that could not be read.
|
|
64
|
+
|
|
65
|
+
Only the OID is set, with ``Status`` 'fetch error', so a listing shows the failure in place of the object.
|
|
66
|
+
"""
|
|
67
|
+
obj = cls.__new__(cls)
|
|
68
|
+
OrderedDict.__init__(obj)
|
|
69
|
+
obj['OID'] = oid
|
|
70
|
+
obj['Status'] = 'fetch error'
|
|
71
|
+
return obj
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MidpointObjectList(list[MidpointObject]):
|
|
75
|
+
"""A list of parsed Midpoint objects, as returned by the MidpointClient listing and search methods."""
|
|
76
|
+
|
|
77
|
+
def find_object(self, search_reference) -> MidpointObject | None:
|
|
78
|
+
"""The first object whose OID or name equals search_reference exactly, or None."""
|
|
79
|
+
for mp_obj in self:
|
|
80
|
+
if search_reference in [mp_obj.get_oid(), mp_obj.get_name()]:
|
|
81
|
+
return mp_obj
|
|
82
|
+
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def filter(self, queryterms: list[str]) -> 'MidpointObjectList':
|
|
86
|
+
"""A new MidpointObjectList of the objects whose values contain the query terms.
|
|
87
|
+
|
|
88
|
+
Matching is case- and accent-insensitive on every value. A term prefixed with ``+`` is
|
|
89
|
+
mandatory; among the other terms at least one must match (none is needed when every
|
|
90
|
+
term is mandatory).
|
|
91
|
+
"""
|
|
92
|
+
selected_objects = MidpointObjectList()
|
|
93
|
+
|
|
94
|
+
mandatory_terms = [t[1:] for t in queryterms if t.startswith('+')]
|
|
95
|
+
optional_terms = [t for t in queryterms if not t.startswith('+')]
|
|
96
|
+
|
|
97
|
+
for obj in self:
|
|
98
|
+
values = [unidecode(str(v)).casefold() for v in obj.values() if v is not None]
|
|
99
|
+
|
|
100
|
+
mandatory_match = all(any(unidecode(term).casefold() in v for v in values) for term in mandatory_terms)
|
|
101
|
+
optional_match = not optional_terms or any(
|
|
102
|
+
unidecode(term).casefold() in v for term in optional_terms for v in values
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if mandatory_match and optional_match:
|
|
106
|
+
selected_objects.append(obj)
|
|
107
|
+
|
|
108
|
+
return selected_objects
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
RUNNING_EXECUTION_STATES = frozenset({TaskExecutionState.RUNNING, TaskExecutionState.RUNNABLE})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _utc_now() -> datetime:
|
|
115
|
+
"""Current time as an aware UTC datetime; isolated so tests can freeze it."""
|
|
116
|
+
return datetime.now(UTC)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _task_execution_state(xml_entity: Element, server_version: MidpointVersion | None) -> str:
|
|
120
|
+
"""Read the task state under the name the server version uses; unknown versions try both names."""
|
|
121
|
+
if server_version is not None:
|
|
122
|
+
field = 'c:executionState' if task_uses_execution_state(server_version) else 'c:executionStatus'
|
|
123
|
+
state = xml_entity.find(field, namespaces)
|
|
124
|
+
else:
|
|
125
|
+
state = xml_entity.find('c:executionState', namespaces)
|
|
126
|
+
if state is None:
|
|
127
|
+
state = xml_entity.find('c:executionStatus', namespaces)
|
|
128
|
+
return state.text if state is not None and state.text is not None else 'n/a'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _task_result_status(xml_entity: Element, server_version: MidpointVersion | None) -> str:
|
|
132
|
+
"""Read the result of the task's last run, empty for a task that never ran.
|
|
133
|
+
|
|
134
|
+
On every supported version this is TaskType/resultStatus. operationExecution/status is the
|
|
135
|
+
result of the last change made to the task object, not of a run, so it is only a guess kept
|
|
136
|
+
for servers whose version is unknown or older than 4.4.
|
|
137
|
+
"""
|
|
138
|
+
rs = xml_entity.find('c:resultStatus', namespaces)
|
|
139
|
+
if rs is None and (server_version is None or not task_uses_execution_state(server_version)):
|
|
140
|
+
rs = xml_entity.find('c:operationExecution/c:status', namespaces)
|
|
141
|
+
return rs.text if rs is not None and rs.text is not None else ''
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _holds_statistics(statistics: Element) -> bool:
|
|
145
|
+
return (
|
|
146
|
+
statistics.find('c:synchronization', namespaces) is not None
|
|
147
|
+
or statistics.find('c:actionsExecuted', namespaces) is not None
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _activity_statistics(activity: Element) -> list[Element]:
|
|
152
|
+
"""Statistics of an activity state, or of its child activities when it holds none itself.
|
|
153
|
+
|
|
154
|
+
A reconciliation or composite activity keeps no synchronization or action statistics of
|
|
155
|
+
its own: each child activity (resourceObjects, remainingShadows, ...) has its own, as seen
|
|
156
|
+
on Midpoint 4.8.14 and 4.10.1. Reading a parent's statistics when it has some, and its
|
|
157
|
+
children's only otherwise, never counts an item twice.
|
|
158
|
+
"""
|
|
159
|
+
statistics = activity.find('c:statistics', namespaces)
|
|
160
|
+
if statistics is not None and _holds_statistics(statistics):
|
|
161
|
+
return [statistics]
|
|
162
|
+
collected: list[Element] = []
|
|
163
|
+
for child in activity.findall('c:activity', namespaces):
|
|
164
|
+
collected.extend(_activity_statistics(child))
|
|
165
|
+
return collected
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _sum_transitions(statistics_list: list[Element]) -> list[tuple[str, str, int]]:
|
|
169
|
+
"""Synchronization transitions as (from, to, count), summed over activities and outcomes.
|
|
170
|
+
|
|
171
|
+
An exclusion is listed as (reason, '', count).
|
|
172
|
+
"""
|
|
173
|
+
totals: dict[tuple[str, str], int] = {}
|
|
174
|
+
for statistics in statistics_list:
|
|
175
|
+
for transition in statistics.findall('c:synchronization/c:transition', namespaces):
|
|
176
|
+
# One counter per outcome (success, failure, skip)
|
|
177
|
+
count = sum(
|
|
178
|
+
safe_get_int(c.find('c:count', namespaces)) for c in transition.findall('c:counter', namespaces)
|
|
179
|
+
)
|
|
180
|
+
keys = []
|
|
181
|
+
if transition.find('c:onSynchronizationStart', namespaces) is not None:
|
|
182
|
+
keys.append(
|
|
183
|
+
(
|
|
184
|
+
safe_get_text(transition.find('c:onSynchronizationStart', namespaces)),
|
|
185
|
+
safe_get_text(transition.find('c:onSynchronizationEnd', namespaces)),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
if transition.find('c:exclusionReason', namespaces) is not None:
|
|
189
|
+
keys.append((safe_get_text(transition.find('c:exclusionReason', namespaces)), ''))
|
|
190
|
+
for key in keys:
|
|
191
|
+
totals[key] = totals.get(key, 0) + count
|
|
192
|
+
return [(start, end, count) for (start, end), count in totals.items()]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _sum_actions(statistics_list: list[Element]) -> list[tuple[str, str, int, int]]:
|
|
196
|
+
"""Executed actions as (object type, operation, successes, failures), summed over activities.
|
|
197
|
+
|
|
198
|
+
Only objectActionsEntry (all actions) is read: resultingObjectActionsEntry summarises the
|
|
199
|
+
same actions per object, so counting both would count them twice.
|
|
200
|
+
"""
|
|
201
|
+
totals: dict[tuple[str, str], tuple[int, int]] = {}
|
|
202
|
+
for statistics in statistics_list:
|
|
203
|
+
for entry in statistics.findall('c:actionsExecuted/c:objectActionsEntry', namespaces):
|
|
204
|
+
key = (
|
|
205
|
+
safe_get_text(entry.find('c:objectType', namespaces)),
|
|
206
|
+
safe_get_text(entry.find('c:operation', namespaces)),
|
|
207
|
+
)
|
|
208
|
+
success, failure = totals.get(key, (0, 0))
|
|
209
|
+
totals[key] = (
|
|
210
|
+
success + safe_get_int(entry.find('c:totalSuccessCount', namespaces)),
|
|
211
|
+
failure + safe_get_int(entry.find('c:totalFailureCount', namespaces)),
|
|
212
|
+
)
|
|
213
|
+
return [(object_type, operation, s, f) for (object_type, operation), (s, f) in totals.items()]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# Task detail tables follow the look of the list tables (prompt.console.create_table).
|
|
217
|
+
_TABLE_BORDER_STYLE = 'blue'
|
|
218
|
+
_TABLE_HEADER_STYLE = 'bold cyan'
|
|
219
|
+
# Colors of a task result status (OperationResultStatusType values).
|
|
220
|
+
_RESULT_STATUS_STYLES = {
|
|
221
|
+
ResultStatus.SUCCESS: 'green',
|
|
222
|
+
ResultStatus.WARNING: 'yellow',
|
|
223
|
+
ResultStatus.PARTIAL_ERROR: 'yellow',
|
|
224
|
+
ResultStatus.HANDLED_ERROR: 'yellow',
|
|
225
|
+
ResultStatus.FATAL_ERROR: 'bold red',
|
|
226
|
+
ResultStatus.IN_PROGRESS: 'cyan',
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Timestamp elements of a task's last run, by preference: the names vary across Midpoint versions.
|
|
231
|
+
_TASK_START_FIELDS = ('c:lastRunStartTimestamp', 'c:startTimestamp')
|
|
232
|
+
_TASK_END_FIELDS = ('c:lastRunFinishTimestamp', 'c:endTimestamp', 'c:completionTimestamp')
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _first_text(xml_entity: Element, paths: tuple[str, ...]) -> str | None:
|
|
236
|
+
"""The text of the first of paths present with a non-empty text in xml_entity, or None."""
|
|
237
|
+
for path in paths:
|
|
238
|
+
elem = xml_entity.find(path, namespaces)
|
|
239
|
+
if elem is not None and elem.text:
|
|
240
|
+
return elem.text
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class MidpointTask(MidpointObject):
|
|
245
|
+
def __init__(self, xml_entity: Element, server_version: MidpointVersion | None = None):
|
|
246
|
+
super().__init__()
|
|
247
|
+
# The keys are set in display order: identity, states, progress, then Duration.
|
|
248
|
+
self['OID'] = xml_entity.attrib['oid']
|
|
249
|
+
name_element = xml_entity.find('c:name', namespaces)
|
|
250
|
+
self['Name'] = name_element.text if name_element is not None else None
|
|
251
|
+
self['Execution Status'] = _task_execution_state(xml_entity, server_version)
|
|
252
|
+
self['Result Status'] = _task_result_status(xml_entity, server_version)
|
|
253
|
+
self._parse_progress(xml_entity)
|
|
254
|
+
|
|
255
|
+
# taskIdentifier is the join key into audit records — distinct from OID,
|
|
256
|
+
# regenerated per run. Not surfaced in the dict to keep the table view tidy.
|
|
257
|
+
task_identifier_el = xml_entity.find('c:taskIdentifier', namespaces)
|
|
258
|
+
self._task_identifier: str | None = task_identifier_el.text if task_identifier_el is not None else None
|
|
259
|
+
|
|
260
|
+
self._parse_timestamps(xml_entity)
|
|
261
|
+
self._parse_statistics(xml_entity)
|
|
262
|
+
|
|
263
|
+
def _parse_progress(self, xml_entity: Element) -> None:
|
|
264
|
+
"""Set Progress and Expected Total, empty when the task does not report them."""
|
|
265
|
+
progress = xml_entity.find('c:progress', namespaces)
|
|
266
|
+
self['Progress'] = progress.text if progress is not None else ''
|
|
267
|
+
total = xml_entity.find('c:expectedTotal', namespaces)
|
|
268
|
+
self['Expected Total'] = total.text if total is not None else ''
|
|
269
|
+
|
|
270
|
+
def _parse_statistics(self, xml_entity: Element) -> None:
|
|
271
|
+
"""Collect the transitions and actions of the task statistics, summed over the activities that hold them."""
|
|
272
|
+
root_activity = xml_entity.find('c:activityState/c:activity', namespaces)
|
|
273
|
+
statistics = _activity_statistics(root_activity) if root_activity is not None else []
|
|
274
|
+
self._transitions: list[tuple[str, str, int]] = _sum_transitions(statistics)
|
|
275
|
+
self._actions: list[tuple[str, str, int, int]] = _sum_actions(statistics)
|
|
276
|
+
|
|
277
|
+
def _parse_timestamps(self, xml_entity: Element) -> None:
|
|
278
|
+
"""Read the start and end of the last run, and set Duration from them."""
|
|
279
|
+
start_timestamp = _first_text(xml_entity, _TASK_START_FIELDS)
|
|
280
|
+
end_timestamp = _first_text(xml_entity, _TASK_END_FIELDS)
|
|
281
|
+
|
|
282
|
+
# Store the parsed start datetime for use by get_start_time()
|
|
283
|
+
self._start_datetime: datetime | None = None
|
|
284
|
+
if start_timestamp:
|
|
285
|
+
self._start_datetime = self._parse_datetime(start_timestamp)
|
|
286
|
+
|
|
287
|
+
# Calculate duration if we have both timestamps
|
|
288
|
+
duration = self._calculate_duration(start_timestamp, end_timestamp)
|
|
289
|
+
self['Duration'] = duration if duration else ''
|
|
290
|
+
|
|
291
|
+
def _parse_datetime(self, timestamp: str) -> datetime | None:
|
|
292
|
+
"""Parse a timestamp string into a datetime object."""
|
|
293
|
+
if not timestamp:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
# Parse timestamps - handle both with and without microseconds
|
|
297
|
+
timestamp_formats = [
|
|
298
|
+
'%Y-%m-%dT%H:%M:%S.%fZ',
|
|
299
|
+
'%Y-%m-%dT%H:%M:%SZ',
|
|
300
|
+
'%Y-%m-%dT%H:%M:%S.%f%z',
|
|
301
|
+
'%Y-%m-%dT%H:%M:%S%z',
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
for fmt in timestamp_formats:
|
|
305
|
+
try:
|
|
306
|
+
return datetime.strptime(timestamp, fmt)
|
|
307
|
+
except ValueError:
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def _calculate_duration(self, start_timestamp: str | None, end_timestamp: str | None) -> str | None:
|
|
313
|
+
"""Calculate duration between two timestamps, or elapsed time for running tasks"""
|
|
314
|
+
if not start_timestamp:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
start_dt = self._parse_datetime(start_timestamp)
|
|
319
|
+
if not start_dt:
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
end_dt = self._parse_datetime(end_timestamp) if end_timestamp else None
|
|
323
|
+
running = self._is_running()
|
|
324
|
+
|
|
325
|
+
# Use the end timestamp when it belongs to the current run. A running task may still
|
|
326
|
+
# carry the finish timestamp of a previous run, which is earlier than the current start.
|
|
327
|
+
if end_dt is not None and (end_dt >= start_dt or not running):
|
|
328
|
+
return self._format_duration((end_dt - start_dt).total_seconds())
|
|
329
|
+
|
|
330
|
+
# Running task without a usable end timestamp: elapsed time since start
|
|
331
|
+
if running:
|
|
332
|
+
now = _utc_now()
|
|
333
|
+
if start_dt.tzinfo is None or start_dt.tzinfo.utcoffset(start_dt) is None:
|
|
334
|
+
now = now.replace(tzinfo=None)
|
|
335
|
+
return self._format_duration((now - start_dt).total_seconds())
|
|
336
|
+
|
|
337
|
+
except (ValueError, TypeError):
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
def _is_running(self) -> bool:
|
|
343
|
+
"""Whether the execution state denotes a running task (Midpoint sends lowercase values)."""
|
|
344
|
+
status = self.get('Execution Status')
|
|
345
|
+
return isinstance(status, str) and status.lower() in RUNNING_EXECUTION_STATES
|
|
346
|
+
|
|
347
|
+
def _format_duration(self, seconds: float) -> str:
|
|
348
|
+
"""Format duration in a human-readable format"""
|
|
349
|
+
if seconds < 0:
|
|
350
|
+
return '0s'
|
|
351
|
+
|
|
352
|
+
hours = int(seconds // 3600)
|
|
353
|
+
minutes = int((seconds % 3600) // 60)
|
|
354
|
+
remaining_seconds = int(seconds % 60)
|
|
355
|
+
|
|
356
|
+
if hours > 0:
|
|
357
|
+
return f'{hours}h {minutes}m {remaining_seconds}s'
|
|
358
|
+
elif minutes > 0:
|
|
359
|
+
return f'{minutes}m {remaining_seconds}s'
|
|
360
|
+
else:
|
|
361
|
+
return f'{remaining_seconds}s'
|
|
362
|
+
|
|
363
|
+
def get_task_identifier(self) -> str | None:
|
|
364
|
+
"""Return the task's `taskIdentifier`, the field stamped on every audit event the task emits."""
|
|
365
|
+
return self._task_identifier
|
|
366
|
+
|
|
367
|
+
def get_start_time(self) -> datetime | None:
|
|
368
|
+
"""Get the task's start time as a datetime object, if available."""
|
|
369
|
+
return getattr(self, '_start_datetime', None)
|
|
370
|
+
|
|
371
|
+
def get_transitions(self) -> list[tuple[str, str, int]]:
|
|
372
|
+
"""Synchronization transitions in the task statistics, as (from situation, to situation, count) tuples.
|
|
373
|
+
|
|
374
|
+
Counts are summed over the task's activities and outcomes; an exclusion is listed as
|
|
375
|
+
(reason, '', count). Empty when the task holds no statistics.
|
|
376
|
+
"""
|
|
377
|
+
return self._transitions
|
|
378
|
+
|
|
379
|
+
def get_actions(self) -> list[tuple[str, str, int, int]]:
|
|
380
|
+
"""Executed actions in the task statistics, as (object type, operation, successes, failures) tuples.
|
|
381
|
+
|
|
382
|
+
Counts are summed over the task's activities. Empty when the task holds no statistics.
|
|
383
|
+
"""
|
|
384
|
+
return self._actions
|
|
385
|
+
|
|
386
|
+
def get_success_count(self) -> int:
|
|
387
|
+
"""Get the total count of successful operations from task statistics."""
|
|
388
|
+
return sum(count_success for _, _, count_success, _ in self._actions)
|
|
389
|
+
|
|
390
|
+
def get_failure_count(self) -> int:
|
|
391
|
+
"""Get the total count of failed operations from task statistics."""
|
|
392
|
+
return sum(count_failure for _, _, _, count_failure in self._actions)
|
|
393
|
+
|
|
394
|
+
def get_full_description(self):
|
|
395
|
+
"""Return a Rich renderable group containing task details as formatted tables.
|
|
396
|
+
|
|
397
|
+
Styled like the list tables (blue borders, bold cyan headers); the result status is
|
|
398
|
+
colored by outcome and failure counts stand out. Colors only, so the text is the
|
|
399
|
+
same on a terminal and in a pipe.
|
|
400
|
+
"""
|
|
401
|
+
# Task Properties Table
|
|
402
|
+
properties_table = Table(title='Task Properties', show_header=False, border_style=_TABLE_BORDER_STYLE)
|
|
403
|
+
properties_table.add_column('Property', style='bold cyan', no_wrap=True)
|
|
404
|
+
properties_table.add_column('Value', style='white')
|
|
405
|
+
for key, value in self.items():
|
|
406
|
+
style = _RESULT_STATUS_STYLES.get(str(value)) if key == 'Result Status' else None
|
|
407
|
+
properties_table.add_row(Text(key), Text(str(value), style=style or ''))
|
|
408
|
+
|
|
409
|
+
# Transitions Table
|
|
410
|
+
transitions_table = Table(
|
|
411
|
+
title='Transitions', border_style=_TABLE_BORDER_STYLE, show_header=True, header_style=_TABLE_HEADER_STYLE
|
|
412
|
+
)
|
|
413
|
+
transitions_table.add_column('From State', style='yellow')
|
|
414
|
+
transitions_table.add_column('To State', style='yellow')
|
|
415
|
+
transitions_table.add_column('Count', style='white', justify='right')
|
|
416
|
+
for from_state, to_state, count in self.get_transitions():
|
|
417
|
+
transitions_table.add_row(Text(from_state or ''), Text(to_state or ''), str(count))
|
|
418
|
+
|
|
419
|
+
# Actions Table
|
|
420
|
+
actions_table = Table(
|
|
421
|
+
title='Actions', border_style=_TABLE_BORDER_STYLE, show_header=True, header_style=_TABLE_HEADER_STYLE
|
|
422
|
+
)
|
|
423
|
+
actions_table.add_column('Type', style='bright_cyan')
|
|
424
|
+
actions_table.add_column('Action', style='white')
|
|
425
|
+
actions_table.add_column('Success', style='green', justify='right')
|
|
426
|
+
actions_table.add_column('Failure', justify='right')
|
|
427
|
+
for obj_type, operation, count_success, count_failure in self.get_actions():
|
|
428
|
+
failure = Text(str(count_failure), style='bold red' if count_failure else 'dim')
|
|
429
|
+
actions_table.add_row(Text(obj_type or ''), Text(operation or ''), str(count_success), failure)
|
|
430
|
+
|
|
431
|
+
# Return a Group containing all tables
|
|
432
|
+
return Group(properties_table, transitions_table, actions_table)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class MidpointResource(MidpointObject):
|
|
436
|
+
def __init__(self, xml_entity: Element):
|
|
437
|
+
super().__init__()
|
|
438
|
+
self['OID'] = xml_entity.attrib['oid']
|
|
439
|
+
self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
|
|
440
|
+
self['Availability Status'] = optional_text(
|
|
441
|
+
xml_entity.find('c:operationalState/c:lastAvailabilityStatus', namespaces)
|
|
442
|
+
)
|
|
443
|
+
connector_ref = xml_entity.find('c:connectorRef', namespaces)
|
|
444
|
+
self['connectorRef'] = connector_ref.attrib['oid'] if connector_ref is not None else None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class MidpointConnector(MidpointObject):
|
|
448
|
+
def __init__(self, xml_entity: Element):
|
|
449
|
+
super().__init__()
|
|
450
|
+
self['OID'] = xml_entity.attrib['oid']
|
|
451
|
+
self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
|
|
452
|
+
self['Connector type'] = safe_get_text(xml_entity.find('c:connectorType', namespaces))
|
|
453
|
+
self['Version'] = safe_get_text(xml_entity.find('c:connectorVersion', namespaces))
|
|
454
|
+
self['Bundle'] = safe_get_text(xml_entity.find('c:connectorBundle', namespaces))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class MidpointUser(MidpointObject):
|
|
458
|
+
@staticmethod
|
|
459
|
+
def strip_namespace(tag: str) -> str:
|
|
460
|
+
"""An ElementTree tag without its ``{namespace}`` prefix, e.g. 'fullName'."""
|
|
461
|
+
return re.sub(r'{.*}', '', tag)
|
|
462
|
+
|
|
463
|
+
def __init__(self, xml_entity: Element):
|
|
464
|
+
super().__init__()
|
|
465
|
+
self['OID'] = xml_entity.attrib['oid']
|
|
466
|
+
self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
|
|
467
|
+
self['Title'] = optional_text(xml_entity.find('c:title', namespaces))
|
|
468
|
+
self['FullName'] = optional_text(xml_entity.find('c:fullName', namespaces))
|
|
469
|
+
self['Status'] = safe_get_text(xml_entity.find('c:activation/c:effectiveStatus', namespaces))
|
|
470
|
+
self['EmpNo'] = optional_text(xml_entity.find('c:employeeNumber', namespaces))
|
|
471
|
+
self['Email'] = optional_text(xml_entity.find('c:emailAddress', namespaces))
|
|
472
|
+
self['OU'] = optional_text(xml_entity.find('c:organizationalUnit', namespaces))
|
|
473
|
+
|
|
474
|
+
activation = xml_entity.find('c:activation', namespaces)
|
|
475
|
+
extfields = xml_entity.find('c:extension', namespaces)
|
|
476
|
+
|
|
477
|
+
self._all_attributes: list[tuple[str, str]] = []
|
|
478
|
+
|
|
479
|
+
for child in itertools.chain(
|
|
480
|
+
xml_entity,
|
|
481
|
+
activation if activation is not None else [],
|
|
482
|
+
extfields if extfields is not None else [],
|
|
483
|
+
):
|
|
484
|
+
# Check if the child has no children and contains non-empty text
|
|
485
|
+
if len(child) == 0 and (child.text is not None and child.text.strip()):
|
|
486
|
+
self._all_attributes.append((self.strip_namespace(child.tag), child.text))
|
|
487
|
+
|
|
488
|
+
self._role_assignment_oids: list[str] = []
|
|
489
|
+
self._role_assignment_id_map: dict[str, str] = {}
|
|
490
|
+
self._org_assignment_oids: list[str] = []
|
|
491
|
+
for assignment in xml_entity.findall('c:assignment', namespaces):
|
|
492
|
+
target_ref = assignment.find('c:targetRef', namespaces)
|
|
493
|
+
if target_ref is not None:
|
|
494
|
+
ref_oid = target_ref.attrib.get('oid')
|
|
495
|
+
ref_type = target_ref.attrib.get('type', '')
|
|
496
|
+
if ref_oid:
|
|
497
|
+
if 'RoleType' in ref_type:
|
|
498
|
+
self._role_assignment_oids.append(ref_oid)
|
|
499
|
+
assignment_id = assignment.attrib.get('id')
|
|
500
|
+
if assignment_id:
|
|
501
|
+
self._role_assignment_id_map[ref_oid] = assignment_id
|
|
502
|
+
elif 'OrgType' in ref_type:
|
|
503
|
+
self._org_assignment_oids.append(ref_oid)
|
|
504
|
+
|
|
505
|
+
self._link_ref_oids: list[str] = []
|
|
506
|
+
for link_ref in xml_entity.findall('c:linkRef', namespaces):
|
|
507
|
+
ref_oid = link_ref.attrib.get('oid')
|
|
508
|
+
if ref_oid:
|
|
509
|
+
self._link_ref_oids.append(ref_oid)
|
|
510
|
+
|
|
511
|
+
def get_all_attributes(self) -> list[tuple[str, str]]:
|
|
512
|
+
"""Every simple property of the user as (local name, text) pairs, in document order.
|
|
513
|
+
|
|
514
|
+
Includes the leaf elements with text directly under the user, under ``activation`` and
|
|
515
|
+
under ``extension``; structured items (assignments, credentials, ...) are left out.
|
|
516
|
+
"""
|
|
517
|
+
return self._all_attributes
|
|
518
|
+
|
|
519
|
+
def get_role_assignment_oids(self) -> list[str]:
|
|
520
|
+
"""OIDs of the roles the user is directly assigned (assignments targeting a RoleType)."""
|
|
521
|
+
return self._role_assignment_oids
|
|
522
|
+
|
|
523
|
+
def get_role_assignment_id(self, role_oid: str) -> str | None:
|
|
524
|
+
"""The container id of the user's assignment of role_oid, or None if not assigned or without id."""
|
|
525
|
+
return self._role_assignment_id_map.get(role_oid)
|
|
526
|
+
|
|
527
|
+
def get_org_assignment_oids(self) -> list[str]:
|
|
528
|
+
"""OIDs of the organizations the user is directly assigned (assignments targeting an OrgType)."""
|
|
529
|
+
return self._org_assignment_oids
|
|
530
|
+
|
|
531
|
+
def get_link_ref_oids(self) -> list[str]:
|
|
532
|
+
"""OIDs of the user's projections: the shadows referenced by ``linkRef``."""
|
|
533
|
+
return self._link_ref_oids
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class MidpointOrganization(MidpointObject):
|
|
537
|
+
def __init__(self, xml_entity: Element):
|
|
538
|
+
super().__init__()
|
|
539
|
+
self['OID'] = xml_entity.attrib['oid']
|
|
540
|
+
self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
|
|
541
|
+
self['DisplayName'] = optional_text(xml_entity.find('c:displayName', namespaces))
|
|
542
|
+
self['Status'] = safe_get_text(xml_entity.find('c:activation/c:effectiveStatus', namespaces))
|
|
543
|
+
parentorg = xml_entity.find('c:parentOrgRef', namespaces)
|
|
544
|
+
self['Parent'] = None if parentorg is None else parentorg.attrib['oid']
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
class MidpointRole(MidpointObject):
|
|
548
|
+
def __init__(self, xml_entity: Element):
|
|
549
|
+
super().__init__()
|
|
550
|
+
self['OID'] = xml_entity.attrib['oid']
|
|
551
|
+
self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
|
|
552
|
+
self['DisplayName'] = optional_text(xml_entity.find('c:displayName', namespaces))
|
|
553
|
+
self['Description'] = optional_text(xml_entity.find('c:description', namespaces))
|
|
554
|
+
self['Status'] = safe_get_text(xml_entity.find('c:activation/c:effectiveStatus', namespaces))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class MidpointAuditEvent(MidpointObject):
|
|
558
|
+
"""One Midpoint AuditEventRecord, parsed for tabular display.
|
|
559
|
+
|
|
560
|
+
Raw <delta> XML elements are kept available via :meth:`get_deltas` so
|
|
561
|
+
callers can render them only when the user explicitly asks (e.g. with
|
|
562
|
+
``--show-deltas``).
|
|
563
|
+
"""
|
|
564
|
+
|
|
565
|
+
def __init__(self, xml_entity: Element):
|
|
566
|
+
super().__init__()
|
|
567
|
+
self['EventIdentifier'] = self._find_text(xml_entity, 'eventIdentifier')
|
|
568
|
+
self['Timestamp'] = self._find_text(xml_entity, 'timestamp')
|
|
569
|
+
self['Stage'] = self._find_text(xml_entity, 'eventStage')
|
|
570
|
+
self['EventType'] = self._find_text(xml_entity, 'eventType')
|
|
571
|
+
self['Initiator'] = self._format_ref(self._find(xml_entity, 'initiatorRef'))
|
|
572
|
+
self['Target'] = self._format_ref(self._find(xml_entity, 'targetRef'))
|
|
573
|
+
self['Channel'] = self._short_channel(self._find_text(xml_entity, 'channel'))
|
|
574
|
+
self['Outcome'] = self._find_text(xml_entity, 'outcome')
|
|
575
|
+
self['Message'] = self._find_text(xml_entity, 'message')
|
|
576
|
+
|
|
577
|
+
self._initiator_oid = self._ref_oid(self._find(xml_entity, 'initiatorRef'))
|
|
578
|
+
self._target_oid = self._ref_oid(self._find(xml_entity, 'targetRef'))
|
|
579
|
+
self._task_identifier = self._find_text(xml_entity, 'taskIdentifier')
|
|
580
|
+
self._session_identifier = self._find_text(xml_entity, 'sessionIdentifier')
|
|
581
|
+
self._request_identifier = self._find_text(xml_entity, 'requestIdentifier')
|
|
582
|
+
self._parameter = self._find_text(xml_entity, 'parameter')
|
|
583
|
+
|
|
584
|
+
self._deltas: list[Element] = self._find_all(xml_entity, 'delta')
|
|
585
|
+
|
|
586
|
+
@staticmethod
|
|
587
|
+
def _find(parent: Element, local_name: str) -> Element | None:
|
|
588
|
+
"""Find a child element by local name across both audit-3 and common-3 namespaces."""
|
|
589
|
+
node = parent.find(f'aud:{local_name}', namespaces)
|
|
590
|
+
if node is None:
|
|
591
|
+
node = parent.find(f'c:{local_name}', namespaces)
|
|
592
|
+
return node
|
|
593
|
+
|
|
594
|
+
@classmethod
|
|
595
|
+
def _find_all(cls, parent: Element, local_name: str) -> list[Element]:
|
|
596
|
+
nodes = parent.findall(f'aud:{local_name}', namespaces)
|
|
597
|
+
if not nodes:
|
|
598
|
+
nodes = parent.findall(f'c:{local_name}', namespaces)
|
|
599
|
+
return list(nodes)
|
|
600
|
+
|
|
601
|
+
@classmethod
|
|
602
|
+
def _find_text(cls, parent: Element, local_name: str) -> str:
|
|
603
|
+
return safe_get_text(cls._find(parent, local_name))
|
|
604
|
+
|
|
605
|
+
@staticmethod
|
|
606
|
+
def _ref_oid(node: Element | None) -> str | None:
|
|
607
|
+
if node is None:
|
|
608
|
+
return None
|
|
609
|
+
return node.attrib.get('oid')
|
|
610
|
+
|
|
611
|
+
@staticmethod
|
|
612
|
+
def _format_ref(node: Element | None) -> str:
|
|
613
|
+
"""Render a *Ref node as ``name (oid)`` when both are present, else whichever exists."""
|
|
614
|
+
if node is None:
|
|
615
|
+
return ''
|
|
616
|
+
oid = node.attrib.get('oid', '')
|
|
617
|
+
# Audit refs store the human-readable name in <aud:description>, regular
|
|
618
|
+
# object refs use <c:name>. Fall back through both.
|
|
619
|
+
name = ''
|
|
620
|
+
for path in ('aud:description', 'c:description', 'aud:name', 'c:name'):
|
|
621
|
+
child = node.find(path, namespaces)
|
|
622
|
+
if child is not None and child.text:
|
|
623
|
+
name = child.text
|
|
624
|
+
break
|
|
625
|
+
if name and oid:
|
|
626
|
+
return f'{name} ({oid})'
|
|
627
|
+
return name or oid
|
|
628
|
+
|
|
629
|
+
@staticmethod
|
|
630
|
+
def _short_channel(channel: str) -> str:
|
|
631
|
+
"""Drop the URI prefix from a channel URI, keep just the trailing fragment."""
|
|
632
|
+
if not channel:
|
|
633
|
+
return ''
|
|
634
|
+
if '#' in channel:
|
|
635
|
+
return channel.rsplit('#', 1)[-1]
|
|
636
|
+
return channel.rsplit('/', 1)[-1]
|
|
637
|
+
|
|
638
|
+
def get_initiator_oid(self) -> str | None:
|
|
639
|
+
"""OID of the object that initiated the event (``initiatorRef``), or None."""
|
|
640
|
+
return self._initiator_oid
|
|
641
|
+
|
|
642
|
+
def get_target_oid(self) -> str | None:
|
|
643
|
+
"""OID of the event's target object (``targetRef``), or None."""
|
|
644
|
+
return self._target_oid
|
|
645
|
+
|
|
646
|
+
def get_task_identifier(self) -> str | None:
|
|
647
|
+
"""The ``taskIdentifier`` of the task that emitted the event, or None."""
|
|
648
|
+
return self._task_identifier or None
|
|
649
|
+
|
|
650
|
+
def get_session_identifier(self) -> str | None:
|
|
651
|
+
"""The event's ``sessionIdentifier``, or None when it has none."""
|
|
652
|
+
return self._session_identifier or None
|
|
653
|
+
|
|
654
|
+
def get_request_identifier(self) -> str | None:
|
|
655
|
+
"""The event's ``requestIdentifier``, shared by the events of one operation, or None."""
|
|
656
|
+
return self._request_identifier or None
|
|
657
|
+
|
|
658
|
+
def get_parameter(self) -> str | None:
|
|
659
|
+
"""The event's free-form ``parameter``, or None when it has none."""
|
|
660
|
+
return self._parameter or None
|
|
661
|
+
|
|
662
|
+
def get_deltas(self) -> list[Element]:
|
|
663
|
+
"""The event's raw ``<delta>`` elements (ObjectDeltaOperationType), empty when it has none."""
|
|
664
|
+
return self._deltas
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
class MidpointShadow(MidpointObject):
|
|
668
|
+
def __init__(self, xml_entity: Element):
|
|
669
|
+
super().__init__()
|
|
670
|
+
self['OID'] = xml_entity.attrib['oid']
|
|
671
|
+
self['Name'] = safe_get_text(xml_entity.find('c:name', namespaces))
|
|
672
|
+
resource_ref = xml_entity.find('c:resourceRef', namespaces)
|
|
673
|
+
self['ResourceOID'] = resource_ref.attrib['oid'] if resource_ref is not None else None
|
|
674
|
+
self['Kind'] = optional_text(xml_entity.find('c:kind', namespaces))
|
|
675
|
+
self['Intent'] = optional_text(xml_entity.find('c:intent', namespaces))
|
|
676
|
+
self['Status'] = safe_get_text(xml_entity.find('c:activation/c:effectiveStatus', namespaces))
|
|
677
|
+
self['Situation'] = safe_get_text(xml_entity.find('c:synchronizationSituation', namespaces))
|
|
678
|
+
# A dead shadow stands for an object deleted from the resource, kept for a while by Midpoint.
|
|
679
|
+
self['Dead'] = safe_get_text(xml_entity.find('c:dead', namespaces))
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@dataclass
|
|
683
|
+
class MidpointObjectType:
|
|
684
|
+
object_type: str | None
|
|
685
|
+
endpoint: str | None
|
|
686
|
+
tagname: str
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
class MidpointTypeNotFound(Exception):
|
|
690
|
+
pass
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
class MidpointObjectTypes(Enum):
|
|
694
|
+
CONNECTOR = MidpointObjectType('ConnectorType', 'connectors', 'connector')
|
|
695
|
+
CONNECTOR_HOST = MidpointObjectType('ConnectorHostType', 'connectorHosts', 'connectorHost')
|
|
696
|
+
GENERIC_OBJECT = MidpointObjectType('GenericObjectType', 'genericObjects', 'genericObject')
|
|
697
|
+
RESOURCE = MidpointObjectType('ResourceType', 'resources', 'resource')
|
|
698
|
+
USER = MidpointObjectType('UserType', 'users', 'user')
|
|
699
|
+
OBJECT_TEMPLATE = MidpointObjectType('ObjectTemplateType', 'objectTemplates', 'objectTemplate')
|
|
700
|
+
SYSTEM_CONFIGURATION = MidpointObjectType('SystemConfigurationType', 'systemConfigurations', 'systemConfiguration')
|
|
701
|
+
TASK = MidpointObjectType('TaskType', 'tasks', 'task')
|
|
702
|
+
SHADOW = MidpointObjectType('ShadowType', 'shadows', 'shadow')
|
|
703
|
+
ROLE = MidpointObjectType('RoleType', 'roles', 'role')
|
|
704
|
+
VALUE_POLICY = MidpointObjectType('ValuePolicyType', 'valuePolicies', 'valuePolicy')
|
|
705
|
+
ORG = MidpointObjectType('OrgType', 'orgs', 'org')
|
|
706
|
+
FUNCTION_LIBRARY = MidpointObjectType('FunctionLibraryType', 'functionLibraries', 'functionLibrary')
|
|
707
|
+
REPORT = MidpointObjectType('ReportType', 'reports', 'report')
|
|
708
|
+
POLICY = MidpointObjectType('PolicyType', 'policies', 'policy')
|
|
709
|
+
# The following REST endpoints are all present in Midpoint 4.4 through 4.11.
|
|
710
|
+
ARCHETYPE = MidpointObjectType('ArchetypeType', 'archetypes', 'archetype')
|
|
711
|
+
SERVICE = MidpointObjectType('ServiceType', 'services', 'service')
|
|
712
|
+
LOOKUP_TABLE = MidpointObjectType('LookupTableType', 'lookupTables', 'lookupTable')
|
|
713
|
+
CASE = MidpointObjectType('CaseType', 'cases', 'case')
|
|
714
|
+
NODE = MidpointObjectType('NodeType', 'nodes', 'node')
|
|
715
|
+
OBJECT_COLLECTION = MidpointObjectType('ObjectCollectionType', 'objectCollections', 'objectCollection')
|
|
716
|
+
DASHBOARD = MidpointObjectType('DashboardType', 'dashboards', 'dashboard')
|
|
717
|
+
SEQUENCE = MidpointObjectType('SequenceType', 'sequences', 'sequence')
|
|
718
|
+
OBJECTS = MidpointObjectType(None, None, 'objects')
|
|
719
|
+
|
|
720
|
+
@staticmethod
|
|
721
|
+
def __find(needle: str, getter: Callable) -> MidpointObjectType:
|
|
722
|
+
for type in MidpointObjectTypes:
|
|
723
|
+
if getter(type.value) == needle:
|
|
724
|
+
return type.value
|
|
725
|
+
|
|
726
|
+
raise MidpointTypeNotFound(f'No type found for value {needle}')
|
|
727
|
+
|
|
728
|
+
# The exact lookups below are not used by the CLI itself, which goes through find_by_name.
|
|
729
|
+
# They are kept as public API for `script run` scripts, which receive the client.
|
|
730
|
+
|
|
731
|
+
@staticmethod
|
|
732
|
+
def find_by_java_type(java_type: str) -> MidpointObjectType:
|
|
733
|
+
"""The type whose schema type name is exactly java_type (e.g. 'UserType').
|
|
734
|
+
|
|
735
|
+
Raises MidpointTypeNotFound when no type has that name. See find_by_name for a lenient lookup.
|
|
736
|
+
"""
|
|
737
|
+
return MidpointObjectTypes.__find(java_type, lambda t: t.object_type)
|
|
738
|
+
|
|
739
|
+
@staticmethod
|
|
740
|
+
def find_by_endpoint(endpoint: str) -> MidpointObjectType:
|
|
741
|
+
"""The type whose REST collection is exactly endpoint (e.g. 'users' for ws/rest/users).
|
|
742
|
+
|
|
743
|
+
Raises MidpointTypeNotFound when no type has that endpoint.
|
|
744
|
+
"""
|
|
745
|
+
return MidpointObjectTypes.__find(endpoint, lambda t: t.endpoint)
|
|
746
|
+
|
|
747
|
+
@staticmethod
|
|
748
|
+
def find_by_tagname(tagname: str) -> MidpointObjectType:
|
|
749
|
+
"""The type whose XML element name is exactly tagname (e.g. 'user'), case-sensitive.
|
|
750
|
+
|
|
751
|
+
Raises MidpointTypeNotFound when no type has that element name. See find_by_name for a lenient lookup.
|
|
752
|
+
"""
|
|
753
|
+
return MidpointObjectTypes.__find(tagname, lambda t: t.tagname)
|
|
754
|
+
|
|
755
|
+
@staticmethod
|
|
756
|
+
def find_by_name(name: str) -> MidpointObjectType:
|
|
757
|
+
"""
|
|
758
|
+
Flexible type lookup that tries multiple matching strategies:
|
|
759
|
+
1. Case-insensitive tagname match (e.g., "objectTemplate", "ObjectTemplate")
|
|
760
|
+
2. Case-insensitive Java type match (e.g., "ObjectTemplateType")
|
|
761
|
+
3. Partial name with Type suffix appended (e.g., "ObjectTemplate" → "ObjectTemplateType")
|
|
762
|
+
4. Partial name with Type suffix removed (e.g., "ObjectTemplateType" → "ObjectTemplate")
|
|
763
|
+
|
|
764
|
+
Raises MidpointTypeNotFound with helpful suggestions if no match is found.
|
|
765
|
+
"""
|
|
766
|
+
if not name:
|
|
767
|
+
raise MidpointTypeNotFound('Type name cannot be empty')
|
|
768
|
+
|
|
769
|
+
name_lower = name.lower()
|
|
770
|
+
|
|
771
|
+
# Strategy 1: Try case-insensitive tagname match
|
|
772
|
+
for mp_type in MidpointObjectTypes:
|
|
773
|
+
if mp_type.value.tagname and mp_type.value.tagname.lower() == name_lower:
|
|
774
|
+
return mp_type.value
|
|
775
|
+
|
|
776
|
+
# Strategy 2: Try case-insensitive Java type match
|
|
777
|
+
for mp_type in MidpointObjectTypes:
|
|
778
|
+
if mp_type.value.object_type and mp_type.value.object_type.lower() == name_lower:
|
|
779
|
+
return mp_type.value
|
|
780
|
+
|
|
781
|
+
# Strategy 3: Try appending "Type" suffix for partial matches
|
|
782
|
+
# (e.g., "ObjectTemplate" → "ObjectTemplateType")
|
|
783
|
+
if not name_lower.endswith('type'):
|
|
784
|
+
name_with_type = name + 'Type'
|
|
785
|
+
name_with_type_lower = name_with_type.lower()
|
|
786
|
+
for mp_type in MidpointObjectTypes:
|
|
787
|
+
if mp_type.value.object_type and mp_type.value.object_type.lower() == name_with_type_lower:
|
|
788
|
+
return mp_type.value
|
|
789
|
+
|
|
790
|
+
# Strategy 4: Try removing "Type" suffix
|
|
791
|
+
# (e.g., "ObjectTemplateType" → "objectTemplate")
|
|
792
|
+
if name_lower.endswith('type'):
|
|
793
|
+
name_without_type = name[:-4] # Remove "Type"
|
|
794
|
+
name_without_type_lower = name_without_type.lower()
|
|
795
|
+
for mp_type in MidpointObjectTypes:
|
|
796
|
+
if mp_type.value.tagname and mp_type.value.tagname.lower() == name_without_type_lower:
|
|
797
|
+
return mp_type.value
|
|
798
|
+
|
|
799
|
+
# No match found - create helpful error message
|
|
800
|
+
valid_types = []
|
|
801
|
+
for mp_type in MidpointObjectTypes:
|
|
802
|
+
if mp_type.value.tagname != 'objects': # Skip the generic "objects" type
|
|
803
|
+
if mp_type.value.object_type:
|
|
804
|
+
valid_types.append(f'{mp_type.value.tagname} ({mp_type.value.object_type})')
|
|
805
|
+
else:
|
|
806
|
+
valid_types.append(mp_type.value.tagname)
|
|
807
|
+
|
|
808
|
+
valid_types_str = ', '.join(sorted(valid_types))
|
|
809
|
+
raise MidpointTypeNotFound(f'No type found for value "{name}". Valid types are: {valid_types_str}')
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def optional_text(node: Element | None) -> str | None:
|
|
813
|
+
"""The text of node, or None when node is missing (unlike safe_get_text, which returns a default)."""
|
|
814
|
+
return node.text if node is not None else None
|