rtls-sdk 0.2.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.
- rtls_sdk/__init__.py +123 -0
- rtls_sdk/_auth.py +266 -0
- rtls_sdk/_client.py +419 -0
- rtls_sdk/_envelope.py +74 -0
- rtls_sdk/_http.py +145 -0
- rtls_sdk/_logging.py +143 -0
- rtls_sdk/_pagination.py +235 -0
- rtls_sdk/_query.py +114 -0
- rtls_sdk/_time.py +84 -0
- rtls_sdk/compounds/__init__.py +19 -0
- rtls_sdk/compounds/auth.py +100 -0
- rtls_sdk/compounds/context.py +159 -0
- rtls_sdk/compounds/groups.py +126 -0
- rtls_sdk/compounds/nodes.py +176 -0
- rtls_sdk/compounds/reports.py +339 -0
- rtls_sdk/compounds/system.py +43 -0
- rtls_sdk/compounds/tags.py +404 -0
- rtls_sdk/compounds/users.py +143 -0
- rtls_sdk/compounds/zones.py +203 -0
- rtls_sdk/errors.py +238 -0
- rtls_sdk/models/__init__.py +73 -0
- rtls_sdk/models/_base.py +46 -0
- rtls_sdk/models/alarm.py +23 -0
- rtls_sdk/models/anchor.py +25 -0
- rtls_sdk/models/area.py +28 -0
- rtls_sdk/models/association.py +48 -0
- rtls_sdk/models/bulk.py +80 -0
- rtls_sdk/models/company.py +32 -0
- rtls_sdk/models/csv_blob.py +40 -0
- rtls_sdk/models/floorplan.py +42 -0
- rtls_sdk/models/group.py +20 -0
- rtls_sdk/models/heatmap.py +37 -0
- rtls_sdk/models/import_result.py +42 -0
- rtls_sdk/models/node.py +28 -0
- rtls_sdk/models/notification.py +38 -0
- rtls_sdk/models/position.py +66 -0
- rtls_sdk/models/project.py +26 -0
- rtls_sdk/models/pws.py +38 -0
- rtls_sdk/models/report.py +34 -0
- rtls_sdk/models/session_context.py +81 -0
- rtls_sdk/models/site.py +31 -0
- rtls_sdk/models/subscriber.py +68 -0
- rtls_sdk/models/system.py +97 -0
- rtls_sdk/models/system_health.py +36 -0
- rtls_sdk/models/tag.py +48 -0
- rtls_sdk/models/tag_template.py +24 -0
- rtls_sdk/models/user.py +121 -0
- rtls_sdk/models/zone.py +27 -0
- rtls_sdk/models/zone_event.py +21 -0
- rtls_sdk/py.typed +0 -0
- rtls_sdk/resources/__init__.py +49 -0
- rtls_sdk/resources/_base.py +63 -0
- rtls_sdk/resources/alarms.py +108 -0
- rtls_sdk/resources/anchors.py +147 -0
- rtls_sdk/resources/areas.py +78 -0
- rtls_sdk/resources/associations.py +157 -0
- rtls_sdk/resources/auth.py +63 -0
- rtls_sdk/resources/companies.py +79 -0
- rtls_sdk/resources/context.py +50 -0
- rtls_sdk/resources/events.py +149 -0
- rtls_sdk/resources/floorplans.py +283 -0
- rtls_sdk/resources/groups.py +99 -0
- rtls_sdk/resources/logger.py +40 -0
- rtls_sdk/resources/messaging.py +51 -0
- rtls_sdk/resources/nodes.py +157 -0
- rtls_sdk/resources/notifications.py +55 -0
- rtls_sdk/resources/projects.py +67 -0
- rtls_sdk/resources/reports.py +180 -0
- rtls_sdk/resources/sites.py +115 -0
- rtls_sdk/resources/subscribers.py +110 -0
- rtls_sdk/resources/system.py +125 -0
- rtls_sdk/resources/tags.py +370 -0
- rtls_sdk/resources/users.py +275 -0
- rtls_sdk/resources/zones.py +199 -0
- rtls_sdk-0.2.0.dist-info/METADATA +141 -0
- rtls_sdk-0.2.0.dist-info/RECORD +78 -0
- rtls_sdk-0.2.0.dist-info/WHEEL +4 -0
- rtls_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Compound tag workflows.
|
|
2
|
+
|
|
3
|
+
Each function here is the multi-call sequence behind one ``TagsAPI``
|
|
4
|
+
compound method. Rollback discipline (DESIGN §4.3 / §4.4 / §4.5):
|
|
5
|
+
|
|
6
|
+
- ``create`` rolls back tag creation if a subsequent step fails.
|
|
7
|
+
- ``update`` cannot truly roll back a partial update; it raises a
|
|
8
|
+
``PartialFailureError`` with stable step names listing what completed.
|
|
9
|
+
- ``delete`` best-effort deletes the attached zone first; failure there
|
|
10
|
+
is logged and the tag delete proceeds.
|
|
11
|
+
|
|
12
|
+
The list of stable step-name strings is in each function's docstring.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
20
|
+
|
|
21
|
+
from ..errors import PartialFailureError, RtlsError
|
|
22
|
+
from ..models import BulkFailure, BulkResult, Tag
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .._client import RtlsClient
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("rtls_sdk.compounds.tags")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ZoneSpec — the dict shape callers pass to attach a zone in one go.
|
|
32
|
+
# Not a typed model in v1: the server accepts many fields and we want
|
|
33
|
+
# to pass through whatever the caller provides verbatim.
|
|
34
|
+
ZoneSpec = dict[str, Any]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Unset:
|
|
38
|
+
"""Sentinel singleton for "argument not supplied" semantics."""
|
|
39
|
+
|
|
40
|
+
_instance: _Unset | None = None
|
|
41
|
+
|
|
42
|
+
def __new__(cls) -> _Unset:
|
|
43
|
+
if cls._instance is None:
|
|
44
|
+
cls._instance = super().__new__(cls)
|
|
45
|
+
return cls._instance
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return "UNSET"
|
|
49
|
+
|
|
50
|
+
def __bool__(self) -> bool:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
UNSET: Final[_Unset] = _Unset()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def create_tag(
|
|
58
|
+
client: RtlsClient,
|
|
59
|
+
name: str,
|
|
60
|
+
*,
|
|
61
|
+
attached_zone: ZoneSpec | None = None,
|
|
62
|
+
groups: list[str] | None = None,
|
|
63
|
+
**fields: Any,
|
|
64
|
+
) -> Tag:
|
|
65
|
+
"""Create a tag, optionally attaching a zone and group memberships.
|
|
66
|
+
|
|
67
|
+
Step names (stable, part of the API contract):
|
|
68
|
+
``create_tag``, ``create_zone``, ``replace_groups``,
|
|
69
|
+
``update_tag_with_zone_uid``.
|
|
70
|
+
|
|
71
|
+
Rollback: if any step after ``create_tag`` fails, the SDK tries to
|
|
72
|
+
``delete_raw`` the freshly-created tag. If that rollback also fails,
|
|
73
|
+
:class:`PartialFailureError` carries both errors and a
|
|
74
|
+
``partial_result`` with the orphaned uid for manual cleanup.
|
|
75
|
+
"""
|
|
76
|
+
completed: list[str] = []
|
|
77
|
+
partial: dict[str, Any] = {"tag_uid": None, "zone_uid": None}
|
|
78
|
+
|
|
79
|
+
# Step 1: create the tag.
|
|
80
|
+
try:
|
|
81
|
+
tag = client.tags.create_raw(name=name, **fields)
|
|
82
|
+
except RtlsError:
|
|
83
|
+
raise
|
|
84
|
+
completed.append("create_tag")
|
|
85
|
+
partial["tag_uid"] = tag.uid
|
|
86
|
+
|
|
87
|
+
# Step 2: optionally attach a zone bound to this tag.
|
|
88
|
+
zone_uid: str | None = None
|
|
89
|
+
if attached_zone is not None:
|
|
90
|
+
try:
|
|
91
|
+
zone_payload = {
|
|
92
|
+
**attached_zone,
|
|
93
|
+
"trackable_object_uid": tag.uid,
|
|
94
|
+
"dynamic": True,
|
|
95
|
+
}
|
|
96
|
+
zone = client.zones.create_raw(name=zone_payload.pop("name"), **zone_payload)
|
|
97
|
+
zone_uid = zone.uid
|
|
98
|
+
except RtlsError as exc:
|
|
99
|
+
_rollback_tag(client, tag.uid, partial=partial)
|
|
100
|
+
raise PartialFailureError(
|
|
101
|
+
f"tags.create: create_zone failed: {exc}",
|
|
102
|
+
completed_steps=completed,
|
|
103
|
+
failed_step="create_zone",
|
|
104
|
+
cause=exc,
|
|
105
|
+
partial_result=partial,
|
|
106
|
+
) from exc
|
|
107
|
+
completed.append("create_zone")
|
|
108
|
+
partial["zone_uid"] = zone_uid
|
|
109
|
+
|
|
110
|
+
# Step 3: optionally set group memberships.
|
|
111
|
+
if groups is not None:
|
|
112
|
+
try:
|
|
113
|
+
client.tags.replace_groups(tag.uid, groups, no_notify=True)
|
|
114
|
+
except RtlsError as exc:
|
|
115
|
+
_rollback_zone(client, zone_uid)
|
|
116
|
+
_rollback_tag(client, tag.uid, partial=partial)
|
|
117
|
+
raise PartialFailureError(
|
|
118
|
+
f"tags.create: replace_groups failed: {exc}",
|
|
119
|
+
completed_steps=completed,
|
|
120
|
+
failed_step="replace_groups",
|
|
121
|
+
cause=exc,
|
|
122
|
+
partial_result=partial,
|
|
123
|
+
) from exc
|
|
124
|
+
completed.append("replace_groups")
|
|
125
|
+
|
|
126
|
+
# Step 4: if we created a bound zone, write self_zone_uid back onto
|
|
127
|
+
# the tag so its server-side state matches the binding.
|
|
128
|
+
final_tag: Tag = tag
|
|
129
|
+
if zone_uid is not None:
|
|
130
|
+
try:
|
|
131
|
+
final_tag = client.tags.update_raw(tag.uid, self_zone_uid=zone_uid)
|
|
132
|
+
except RtlsError as exc:
|
|
133
|
+
# Don't roll back here — the tag exists, the zone exists,
|
|
134
|
+
# they're bound on the zone side. The user can recover by
|
|
135
|
+
# calling tags.update(uid, attached_zone=...) themselves.
|
|
136
|
+
raise PartialFailureError(
|
|
137
|
+
f"tags.create: update_tag_with_zone_uid failed: {exc}",
|
|
138
|
+
completed_steps=completed,
|
|
139
|
+
failed_step="update_tag_with_zone_uid",
|
|
140
|
+
cause=exc,
|
|
141
|
+
partial_result=partial,
|
|
142
|
+
) from exc
|
|
143
|
+
completed.append("update_tag_with_zone_uid")
|
|
144
|
+
|
|
145
|
+
return final_tag
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _rollback_tag(client: RtlsClient, uid: str, *, partial: dict[str, Any]) -> None:
|
|
149
|
+
"""Best-effort delete of a tag created by a now-failing compound."""
|
|
150
|
+
try:
|
|
151
|
+
client.tags.delete_raw(uid)
|
|
152
|
+
partial["tag_uid"] = None
|
|
153
|
+
except RtlsError as exc:
|
|
154
|
+
logger.warning("rollback of created tag %s failed: %s", uid, exc)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _rollback_zone(client: RtlsClient, uid: str | None) -> None:
|
|
158
|
+
if uid is None:
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
client.zones.delete_raw(uid)
|
|
162
|
+
except RtlsError as exc:
|
|
163
|
+
logger.warning("rollback of created zone %s failed: %s", uid, exc)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def update_tag(
|
|
167
|
+
client: RtlsClient,
|
|
168
|
+
uid: str,
|
|
169
|
+
*,
|
|
170
|
+
attached_zone: ZoneSpec | None | _Unset = UNSET,
|
|
171
|
+
groups: list[str] | None | _Unset = UNSET,
|
|
172
|
+
**fields: Any,
|
|
173
|
+
) -> Tag:
|
|
174
|
+
"""Diff-driven update of a tag's fields, attached zone, and groups.
|
|
175
|
+
|
|
176
|
+
Sentinel ``...`` marks "leave unchanged". Pass ``attached_zone=None``
|
|
177
|
+
to explicitly *remove* the bound zone; pass a dict to set/replace it.
|
|
178
|
+
Same for ``groups``.
|
|
179
|
+
|
|
180
|
+
Step names (stable):
|
|
181
|
+
``load_current_tag``, ``delete_attached_zone``,
|
|
182
|
+
``create_attached_zone``, ``update_attached_zone``,
|
|
183
|
+
``replace_groups``, ``update_tag``.
|
|
184
|
+
|
|
185
|
+
No rollback path — partial updates cannot be unwound cleanly. On
|
|
186
|
+
failure the SDK raises :class:`PartialFailureError` listing which
|
|
187
|
+
steps completed; the user reconciles state.
|
|
188
|
+
"""
|
|
189
|
+
completed: list[str] = []
|
|
190
|
+
|
|
191
|
+
# Step 1: load current state so we can compute the zone diff.
|
|
192
|
+
try:
|
|
193
|
+
current = client.tags.get(uid)
|
|
194
|
+
except RtlsError as exc:
|
|
195
|
+
raise PartialFailureError(
|
|
196
|
+
f"tags.update: load_current_tag failed: {exc}",
|
|
197
|
+
completed_steps=completed,
|
|
198
|
+
failed_step="load_current_tag",
|
|
199
|
+
cause=exc,
|
|
200
|
+
) from exc
|
|
201
|
+
completed.append("load_current_tag")
|
|
202
|
+
|
|
203
|
+
# Step 2: zone diff. UNSET means "leave unchanged".
|
|
204
|
+
if not isinstance(attached_zone, _Unset):
|
|
205
|
+
existing_zone_uid = current.self_zone_uid
|
|
206
|
+
if attached_zone is None and existing_zone_uid is not None:
|
|
207
|
+
# Removing.
|
|
208
|
+
step = "delete_attached_zone"
|
|
209
|
+
try:
|
|
210
|
+
client.zones.delete_raw(existing_zone_uid)
|
|
211
|
+
except RtlsError as exc:
|
|
212
|
+
raise PartialFailureError(
|
|
213
|
+
f"tags.update: {step} failed: {exc}",
|
|
214
|
+
completed_steps=completed,
|
|
215
|
+
failed_step=step,
|
|
216
|
+
cause=exc,
|
|
217
|
+
) from exc
|
|
218
|
+
completed.append(step)
|
|
219
|
+
fields["self_zone_uid"] = None
|
|
220
|
+
elif attached_zone is not None and existing_zone_uid is None:
|
|
221
|
+
# Creating new bound zone.
|
|
222
|
+
step = "create_attached_zone"
|
|
223
|
+
try:
|
|
224
|
+
payload = {
|
|
225
|
+
**attached_zone,
|
|
226
|
+
"trackable_object_uid": uid,
|
|
227
|
+
"dynamic": True,
|
|
228
|
+
}
|
|
229
|
+
new_zone = client.zones.create_raw(name=payload.pop("name"), **payload)
|
|
230
|
+
except RtlsError as exc:
|
|
231
|
+
raise PartialFailureError(
|
|
232
|
+
f"tags.update: {step} failed: {exc}",
|
|
233
|
+
completed_steps=completed,
|
|
234
|
+
failed_step=step,
|
|
235
|
+
cause=exc,
|
|
236
|
+
) from exc
|
|
237
|
+
completed.append(step)
|
|
238
|
+
fields["self_zone_uid"] = new_zone.uid
|
|
239
|
+
elif attached_zone is not None and existing_zone_uid is not None:
|
|
240
|
+
# Updating existing bound zone.
|
|
241
|
+
step = "update_attached_zone"
|
|
242
|
+
try:
|
|
243
|
+
client.zones.update_raw(existing_zone_uid, **attached_zone)
|
|
244
|
+
except RtlsError as exc:
|
|
245
|
+
raise PartialFailureError(
|
|
246
|
+
f"tags.update: {step} failed: {exc}",
|
|
247
|
+
completed_steps=completed,
|
|
248
|
+
failed_step=step,
|
|
249
|
+
cause=exc,
|
|
250
|
+
) from exc
|
|
251
|
+
completed.append(step)
|
|
252
|
+
|
|
253
|
+
# Step 3: groups diff.
|
|
254
|
+
if not isinstance(groups, _Unset):
|
|
255
|
+
step = "replace_groups"
|
|
256
|
+
try:
|
|
257
|
+
client.tags.replace_groups(uid, groups or [], no_notify=True)
|
|
258
|
+
except RtlsError as exc:
|
|
259
|
+
raise PartialFailureError(
|
|
260
|
+
f"tags.update: {step} failed: {exc}",
|
|
261
|
+
completed_steps=completed,
|
|
262
|
+
failed_step=step,
|
|
263
|
+
cause=exc,
|
|
264
|
+
) from exc
|
|
265
|
+
completed.append(step)
|
|
266
|
+
|
|
267
|
+
# Step 4: write remaining field updates onto the tag.
|
|
268
|
+
if fields:
|
|
269
|
+
step = "update_tag"
|
|
270
|
+
try:
|
|
271
|
+
tag = client.tags.update_raw(uid, **fields)
|
|
272
|
+
except RtlsError as exc:
|
|
273
|
+
raise PartialFailureError(
|
|
274
|
+
f"tags.update: {step} failed: {exc}",
|
|
275
|
+
completed_steps=completed,
|
|
276
|
+
failed_step=step,
|
|
277
|
+
cause=exc,
|
|
278
|
+
) from exc
|
|
279
|
+
completed.append(step)
|
|
280
|
+
return tag
|
|
281
|
+
|
|
282
|
+
# No-op update — re-fetch to give the caller a fresh view.
|
|
283
|
+
return client.tags.get(uid)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def delete_tag(client: RtlsClient, target: Tag | str, *, skip_load: bool = False) -> None:
|
|
287
|
+
"""Delete a tag, cascading to its attached zone if any.
|
|
288
|
+
|
|
289
|
+
Step names: ``load_tag``, ``delete_attached_zone``, ``delete_tag``.
|
|
290
|
+
|
|
291
|
+
``delete_attached_zone`` failure is logged at WARNING and the
|
|
292
|
+
cascade continues — leaving an orphan zone behind is preferable to
|
|
293
|
+
leaving a half-deleted tag.
|
|
294
|
+
|
|
295
|
+
Pass a :class:`Tag` instance to skip the load step.
|
|
296
|
+
"""
|
|
297
|
+
completed: list[str] = []
|
|
298
|
+
if isinstance(target, Tag):
|
|
299
|
+
tag: Tag = target
|
|
300
|
+
elif skip_load:
|
|
301
|
+
tag = Tag.from_wire({"uid": target, "name": "", "self_zone_uid": None})
|
|
302
|
+
else:
|
|
303
|
+
try:
|
|
304
|
+
tag = client.tags.get(target)
|
|
305
|
+
except RtlsError as exc:
|
|
306
|
+
raise PartialFailureError(
|
|
307
|
+
f"tags.delete: load_tag failed: {exc}",
|
|
308
|
+
completed_steps=completed,
|
|
309
|
+
failed_step="load_tag",
|
|
310
|
+
cause=exc,
|
|
311
|
+
) from exc
|
|
312
|
+
completed.append("load_tag")
|
|
313
|
+
|
|
314
|
+
if tag.self_zone_uid:
|
|
315
|
+
try:
|
|
316
|
+
client.zones.delete_raw(tag.self_zone_uid)
|
|
317
|
+
completed.append("delete_attached_zone")
|
|
318
|
+
except RtlsError as exc:
|
|
319
|
+
logger.warning(
|
|
320
|
+
"tags.delete: orphan attached zone %s on tag %s (%s)",
|
|
321
|
+
tag.self_zone_uid,
|
|
322
|
+
tag.uid,
|
|
323
|
+
exc,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
client.tags.delete_raw(tag.uid)
|
|
328
|
+
except RtlsError as exc:
|
|
329
|
+
raise PartialFailureError(
|
|
330
|
+
f"tags.delete: delete_tag failed: {exc}",
|
|
331
|
+
completed_steps=completed,
|
|
332
|
+
failed_step="delete_tag",
|
|
333
|
+
cause=exc,
|
|
334
|
+
partial_result={"tag_uid": tag.uid},
|
|
335
|
+
) from exc
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def bulk_delete_tags(client: RtlsClient, targets: list[Tag | str]) -> BulkResult[None]:
|
|
339
|
+
"""Loop :func:`delete_tag` over a list with continue-on-error semantics."""
|
|
340
|
+
result: BulkResult[None] = BulkResult()
|
|
341
|
+
for target in targets:
|
|
342
|
+
try:
|
|
343
|
+
delete_tag(client, target)
|
|
344
|
+
result.succeeded.append(None)
|
|
345
|
+
except RtlsError as exc:
|
|
346
|
+
uid = target.uid if isinstance(target, Tag) else target
|
|
347
|
+
result.failures.append(BulkFailure(error=exc, input=target, uid=uid))
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def bulk_update_tags(
|
|
352
|
+
client: RtlsClient, uids: list[str], changes: dict[str, Any]
|
|
353
|
+
) -> BulkResult[Tag]:
|
|
354
|
+
"""Loop :func:`update_tag` over a list with continue-on-error.
|
|
355
|
+
|
|
356
|
+
``changes`` is applied identically to each uid. ``attached_zone`` and
|
|
357
|
+
``groups`` are treated as ordinary fields here — they're delegated
|
|
358
|
+
to :func:`update_tag` which interprets them.
|
|
359
|
+
"""
|
|
360
|
+
attached_zone = changes.pop("attached_zone", UNSET)
|
|
361
|
+
groups = changes.pop("groups", UNSET)
|
|
362
|
+
result: BulkResult[Tag] = BulkResult()
|
|
363
|
+
for uid in uids:
|
|
364
|
+
try:
|
|
365
|
+
tag = update_tag(
|
|
366
|
+
client,
|
|
367
|
+
uid,
|
|
368
|
+
attached_zone=attached_zone,
|
|
369
|
+
groups=groups,
|
|
370
|
+
**changes,
|
|
371
|
+
)
|
|
372
|
+
result.succeeded.append(tag)
|
|
373
|
+
except RtlsError as exc:
|
|
374
|
+
result.failures.append(BulkFailure(error=exc, input=uid, uid=uid))
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def list_active_in_period(
|
|
379
|
+
client: RtlsClient,
|
|
380
|
+
sublocation_uid: str,
|
|
381
|
+
start: datetime,
|
|
382
|
+
end: datetime,
|
|
383
|
+
) -> list[Tag]:
|
|
384
|
+
"""Inner-join trackables and active associations over a window.
|
|
385
|
+
|
|
386
|
+
Two underlying calls — both are required; if either fails, raise
|
|
387
|
+
(rather than return a half-joined result that pretends to be
|
|
388
|
+
complete).
|
|
389
|
+
"""
|
|
390
|
+
tags = client.tags.list_active_for_period(start, end, sublocation_uid=sublocation_uid)
|
|
391
|
+
associations = client.associations.list_active_in_period(start, end)
|
|
392
|
+
bound_uids = {assoc.obj_uid for assoc in associations if assoc.obj_uid}
|
|
393
|
+
return [tag for tag in tags if tag.uid in bound_uids]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
__all__ = [
|
|
397
|
+
"ZoneSpec",
|
|
398
|
+
"bulk_delete_tags",
|
|
399
|
+
"bulk_update_tags",
|
|
400
|
+
"create_tag",
|
|
401
|
+
"delete_tag",
|
|
402
|
+
"list_active_in_period",
|
|
403
|
+
"update_tag",
|
|
404
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Compound user workflows.
|
|
2
|
+
|
|
3
|
+
Step-name conventions:
|
|
4
|
+
|
|
5
|
+
- ``create``: ``create_user``, ``bind_project_role``, ``delete_user_rollback``.
|
|
6
|
+
- ``update``: ``update_user``, ``bind_project_role``.
|
|
7
|
+
|
|
8
|
+
A successful ``create`` rolls back the freshly-created user if the
|
|
9
|
+
project-role binding step fails. ``update`` does not have a clean
|
|
10
|
+
rollback path — partial updates land in ``PartialFailureError`` with
|
|
11
|
+
``completed_steps`` for the caller to reconcile.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from ..errors import PartialFailureError, RtlsError
|
|
20
|
+
from ..models import Role, User
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .._client import RtlsClient
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("rtls_sdk.compounds.users")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_user(
|
|
30
|
+
client: RtlsClient,
|
|
31
|
+
email: str,
|
|
32
|
+
password: str,
|
|
33
|
+
*,
|
|
34
|
+
project_uid: str | None = None,
|
|
35
|
+
project_role: str | Role | None = None,
|
|
36
|
+
**fields: Any,
|
|
37
|
+
) -> User:
|
|
38
|
+
"""Create a user and optionally bind them to a project role.
|
|
39
|
+
|
|
40
|
+
Step names: ``create_user``, ``bind_project_role``,
|
|
41
|
+
``delete_user_rollback``.
|
|
42
|
+
|
|
43
|
+
If the user comes back from the server as ``COMPANY_ADMIN``, the
|
|
44
|
+
role binding is skipped — they already have implicit access at the
|
|
45
|
+
company level (matches the JS reference client's check).
|
|
46
|
+
"""
|
|
47
|
+
user = client.users.create_raw(email, password, **fields)
|
|
48
|
+
completed = ["create_user"]
|
|
49
|
+
|
|
50
|
+
if project_role is not None and project_uid is not None:
|
|
51
|
+
if user.role == Role.COMPANY_ADMIN:
|
|
52
|
+
# Company admins don't need per-project role assignment.
|
|
53
|
+
return user
|
|
54
|
+
|
|
55
|
+
role_str = project_role.value if isinstance(project_role, Role) else project_role
|
|
56
|
+
try:
|
|
57
|
+
client.users.update_access(project_uid, uid=user.uid or "", role=role_str)
|
|
58
|
+
except RtlsError as exc:
|
|
59
|
+
# Roll back the user create.
|
|
60
|
+
rollback_uid = user.uid
|
|
61
|
+
try:
|
|
62
|
+
if rollback_uid:
|
|
63
|
+
client.users.delete(rollback_uid)
|
|
64
|
+
completed.append("delete_user_rollback")
|
|
65
|
+
partial: dict[str, Any] = {"user_uid": None}
|
|
66
|
+
except RtlsError as rollback_exc:
|
|
67
|
+
logger.warning(
|
|
68
|
+
"rollback of created user %s failed: %s",
|
|
69
|
+
rollback_uid,
|
|
70
|
+
rollback_exc,
|
|
71
|
+
)
|
|
72
|
+
partial = {"user_uid": rollback_uid}
|
|
73
|
+
raise PartialFailureError(
|
|
74
|
+
f"users.create: bind_project_role failed: {exc}",
|
|
75
|
+
completed_steps=completed,
|
|
76
|
+
failed_step="bind_project_role",
|
|
77
|
+
cause=exc,
|
|
78
|
+
partial_result=partial,
|
|
79
|
+
) from exc
|
|
80
|
+
return user
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def update_user(
|
|
84
|
+
client: RtlsClient,
|
|
85
|
+
uid: str,
|
|
86
|
+
*,
|
|
87
|
+
project_uid: str | None = None,
|
|
88
|
+
project_role: str | Role | None = None,
|
|
89
|
+
**fields: Any,
|
|
90
|
+
) -> User:
|
|
91
|
+
"""Update a user's profile + (optionally) their project role.
|
|
92
|
+
|
|
93
|
+
Step names: ``update_user``, ``bind_project_role``.
|
|
94
|
+
|
|
95
|
+
No rollback — server-side updates can't be cleanly reversed. On
|
|
96
|
+
partial failure the SDK raises :class:`PartialFailureError` with
|
|
97
|
+
``completed_steps=["update_user"]`` so the caller knows the user's
|
|
98
|
+
fields were updated but their role binding wasn't.
|
|
99
|
+
|
|
100
|
+
Self-edit handling: if the user being updated is the SDK's current
|
|
101
|
+
user, and the update changes the email, the SDK refreshes the
|
|
102
|
+
stored email so subsequent ``X-User-Email`` headers stay valid.
|
|
103
|
+
"""
|
|
104
|
+
# Identify self-edit BEFORE the update so we know what to refresh
|
|
105
|
+
# after. The lazy lookup of user_uid via me() is gated on email
|
|
106
|
+
# being part of the update — no extra GET if we're not changing
|
|
107
|
+
# email or scope.
|
|
108
|
+
self_uid = client._auth_state.user_uid
|
|
109
|
+
if self_uid is None and "email" in fields:
|
|
110
|
+
try:
|
|
111
|
+
self_uid = client.users.me().uid
|
|
112
|
+
except RtlsError:
|
|
113
|
+
self_uid = None
|
|
114
|
+
|
|
115
|
+
user = client.users.update_raw(uid, **fields)
|
|
116
|
+
completed = ["update_user"]
|
|
117
|
+
|
|
118
|
+
# Self-edit detection: refresh the auth state's cached email.
|
|
119
|
+
if self_uid and uid == self_uid:
|
|
120
|
+
new_email = fields.get("email")
|
|
121
|
+
if isinstance(new_email, str):
|
|
122
|
+
client._auth_state._email = new_email
|
|
123
|
+
|
|
124
|
+
if project_role is not None and project_uid is not None:
|
|
125
|
+
if user.role == Role.COMPANY_ADMIN:
|
|
126
|
+
return user
|
|
127
|
+
|
|
128
|
+
role_str = project_role.value if isinstance(project_role, Role) else project_role
|
|
129
|
+
try:
|
|
130
|
+
client.users.update_access(project_uid, uid=uid, role=role_str)
|
|
131
|
+
except RtlsError as exc:
|
|
132
|
+
raise PartialFailureError(
|
|
133
|
+
f"users.update: bind_project_role failed: {exc}",
|
|
134
|
+
completed_steps=completed,
|
|
135
|
+
failed_step="bind_project_role",
|
|
136
|
+
cause=exc,
|
|
137
|
+
) from exc
|
|
138
|
+
completed.append("bind_project_role")
|
|
139
|
+
|
|
140
|
+
return user
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = ["create_user", "update_user"]
|