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,159 @@
|
|
|
1
|
+
"""Session-bootstrap compound — DESIGN §4.2.
|
|
2
|
+
|
|
3
|
+
``collect_session_context(client, scope=...)`` fans out the ~20 sub-calls
|
|
4
|
+
that hydrate the deployment view used by every operator UI. Sequential
|
|
5
|
+
in v1 (sync SDK); a v2 async surface would parallelize.
|
|
6
|
+
|
|
7
|
+
Continue-on-error: per-sub-call ``RtlsError`` lands on
|
|
8
|
+
``SessionContext.errors`` keyed by stable sub-resource name. Transport
|
|
9
|
+
failures (``ConnectionError`` and other non-``RtlsError`` exceptions)
|
|
10
|
+
propagate — there's no point assembling a partial snapshot if the
|
|
11
|
+
network is down.
|
|
12
|
+
|
|
13
|
+
Conditional sub-resources (handled specially):
|
|
14
|
+
|
|
15
|
+
- **anchors**: module may not be installed. ``NotFound`` /
|
|
16
|
+
``PermissionDenied`` on that endpoint is treated as "feature absent",
|
|
17
|
+
NOT as an error — anchors stays empty and the error is not recorded.
|
|
18
|
+
- **nodes**: must be fetched at company-scope (RESEARCH §4 "Load all
|
|
19
|
+
entities for current scope"). The SDK uses ``_call_with_scope(
|
|
20
|
+
company_uid=..., clear_project=True)`` to strip ``X-User-Project``
|
|
21
|
+
and add ``X-User-Subcontractor`` for that one call only.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from ..errors import (
|
|
32
|
+
ConnectionError as RtlsConnectionError,
|
|
33
|
+
)
|
|
34
|
+
from ..errors import (
|
|
35
|
+
NotFound,
|
|
36
|
+
PermissionDenied,
|
|
37
|
+
RtlsError,
|
|
38
|
+
)
|
|
39
|
+
from ..models import SessionContext
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from .._client import RtlsClient
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("rtls_sdk.compounds.context")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Sub-resources whose 404 / 403 is treated as "module absent", not as
|
|
49
|
+
# a hard error. Currently just anchors; documented stably so callers
|
|
50
|
+
# can rely on the absence-of-an-errors-entry signal.
|
|
51
|
+
_SOFT_OPTIONAL: frozenset[str] = frozenset({"anchors"})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def collect_session_context(client: RtlsClient, *, scope: str | None = None) -> SessionContext:
|
|
55
|
+
"""Hydrate a :class:`SessionContext` under the active project scope.
|
|
56
|
+
|
|
57
|
+
Only ``scope="project"`` (or ``None``, equivalent) is implemented in
|
|
58
|
+
v1. ``"company"`` / ``"system"`` variants raise
|
|
59
|
+
:class:`NotImplementedError`.
|
|
60
|
+
"""
|
|
61
|
+
if scope not in (None, "project"):
|
|
62
|
+
raise NotImplementedError(
|
|
63
|
+
f"context.load(scope={scope!r}) is not implemented in v1. "
|
|
64
|
+
"Only the default project scope is supported; company / "
|
|
65
|
+
"system bundles are a v2 candidate (DESIGN §9 R4)."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
result = SessionContext()
|
|
69
|
+
|
|
70
|
+
# Each sub-resource: (name, fetcher, setter).
|
|
71
|
+
# The setter writes onto ``result``; we keep it explicit so mypy
|
|
72
|
+
# doesn't need to verify a getattr-based generic assignment.
|
|
73
|
+
sub_resources: list[tuple[str, Callable[[], Any], Callable[[SessionContext, Any], None]]] = [
|
|
74
|
+
("sites", client.sites.list, _setter("sites")),
|
|
75
|
+
("areas", client.areas.list, _setter("areas")),
|
|
76
|
+
("floorplans", client.floorplans.list, _setter("floorplans")),
|
|
77
|
+
("tags", client.tags.list, _setter("tags")),
|
|
78
|
+
("associations", client.associations.list, _setter("associations")),
|
|
79
|
+
("zones", client.zones.list, _setter("zones")),
|
|
80
|
+
(
|
|
81
|
+
"zone_prototypes",
|
|
82
|
+
client.zones.list_custom_prototypes,
|
|
83
|
+
_setter("zone_prototypes"),
|
|
84
|
+
),
|
|
85
|
+
("groups", client.groups.list, _setter("groups")),
|
|
86
|
+
("alarms", client.alarms.list_active, _setter("alarms")),
|
|
87
|
+
("notifications", client.notifications.list, _setter("notifications")),
|
|
88
|
+
(
|
|
89
|
+
"notification_types",
|
|
90
|
+
client.notifications.list_types,
|
|
91
|
+
_setter("notification_types"),
|
|
92
|
+
),
|
|
93
|
+
("subscribers", client.subscribers.list, _setter("subscribers")),
|
|
94
|
+
(
|
|
95
|
+
"system_subscribers",
|
|
96
|
+
client.subscribers.list_system_notification_subscribers,
|
|
97
|
+
_setter("system_subscribers"),
|
|
98
|
+
),
|
|
99
|
+
("users", client.users.list, _setter("users")),
|
|
100
|
+
("user_roles", client.users.list_roles, _setter("user_roles")),
|
|
101
|
+
("tag_templates", client.tags.list_templates, _setter("tag_templates")),
|
|
102
|
+
("helper_link", client.system.helper_link, _setter("helper_link")),
|
|
103
|
+
("monitor_link", client.system.monitor_link, _setter("monitor_link")),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
for name, fetcher, setter in sub_resources:
|
|
107
|
+
try:
|
|
108
|
+
value = fetcher()
|
|
109
|
+
except RtlsConnectionError:
|
|
110
|
+
# Transport-level failure (DNS, refused, timeout) — propagate.
|
|
111
|
+
# There's no point assembling a partial snapshot when the
|
|
112
|
+
# network is down (DESIGN §4.2).
|
|
113
|
+
raise
|
|
114
|
+
except RtlsError as exc:
|
|
115
|
+
logger.warning("context.load: %s failed: %s", name, exc)
|
|
116
|
+
result.errors[name] = exc
|
|
117
|
+
continue
|
|
118
|
+
setter(result, value)
|
|
119
|
+
|
|
120
|
+
# -- anchors (optional module) -----------------------------------
|
|
121
|
+
try:
|
|
122
|
+
result.anchors = client.anchors.list()
|
|
123
|
+
except (NotFound, PermissionDenied):
|
|
124
|
+
# Module not installed on this deployment — silent skip.
|
|
125
|
+
# Crucially we do NOT add this to result.errors; callers use
|
|
126
|
+
# the absence of an "anchors" entry as the "module not present"
|
|
127
|
+
# signal (DESIGN §4.2 / RESEARCH §4 "Load all entities").
|
|
128
|
+
logger.debug("context.load: anchors module not present, skipped")
|
|
129
|
+
except RtlsConnectionError:
|
|
130
|
+
raise
|
|
131
|
+
except RtlsError as exc:
|
|
132
|
+
logger.warning("context.load: anchors failed: %s", exc)
|
|
133
|
+
result.errors["anchors"] = exc
|
|
134
|
+
|
|
135
|
+
# -- nodes (cross-scope: company, not project) -------------------
|
|
136
|
+
company_uid = client._auth_state.company_uid
|
|
137
|
+
try:
|
|
138
|
+
with client._call_with_scope(company_uid=company_uid or "", clear_project=True):
|
|
139
|
+
result.nodes = client.nodes.list()
|
|
140
|
+
except RtlsConnectionError:
|
|
141
|
+
raise
|
|
142
|
+
except RtlsError as exc:
|
|
143
|
+
logger.warning("context.load: nodes failed: %s", exc)
|
|
144
|
+
result.errors["nodes"] = exc
|
|
145
|
+
|
|
146
|
+
# Stamp completion time and the partial flag.
|
|
147
|
+
result.loaded_at = datetime.now(timezone.utc)
|
|
148
|
+
result.partial = bool(result.errors)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _setter(field_name: str) -> Callable[[SessionContext, Any], None]:
|
|
153
|
+
def assign(ctx: SessionContext, value: Any) -> None:
|
|
154
|
+
setattr(ctx, field_name, value)
|
|
155
|
+
|
|
156
|
+
return assign
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["collect_session_context"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Compound group workflows.
|
|
2
|
+
|
|
3
|
+
Step-name conventions:
|
|
4
|
+
|
|
5
|
+
- ``delete``: ``load_member_tags``, ``detach_from_tag``, ``delete_group``.
|
|
6
|
+
- ``add_tags`` / ``remove_tags``: per-tag continue-on-error; no shared
|
|
7
|
+
step-names — the BulkResult carries per-row errors.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from ..errors import PartialFailureError, RtlsError
|
|
16
|
+
from ..models import BulkFailure, BulkResult
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .._client import RtlsClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("rtls_sdk.compounds.groups")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def delete_group(client: RtlsClient, uid: str) -> None:
|
|
26
|
+
"""Delete a group, detaching its trackable memberships first.
|
|
27
|
+
|
|
28
|
+
The server has no dedicated "tags in group X" endpoint per the JS
|
|
29
|
+
reference client (RESEARCH §4 "Delete group") — the SDK lists all
|
|
30
|
+
tags and filters client-side. For each member, ``replace_groups`` is
|
|
31
|
+
called with the group removed.
|
|
32
|
+
|
|
33
|
+
Detach failures are aggregated but do NOT abort the cascade — the
|
|
34
|
+
group is still deleted afterwards. The exception's ``partial_result``
|
|
35
|
+
names every tag that still carries a stale reference, so the caller
|
|
36
|
+
can clean up.
|
|
37
|
+
"""
|
|
38
|
+
completed: list[str] = []
|
|
39
|
+
|
|
40
|
+
# Step 1: find member trackables.
|
|
41
|
+
try:
|
|
42
|
+
all_tags = client.tags.list()
|
|
43
|
+
except RtlsError as exc:
|
|
44
|
+
raise PartialFailureError(
|
|
45
|
+
f"groups.delete: load_member_tags failed: {exc}",
|
|
46
|
+
completed_steps=completed,
|
|
47
|
+
failed_step="load_member_tags",
|
|
48
|
+
cause=exc,
|
|
49
|
+
) from exc
|
|
50
|
+
completed.append("load_member_tags")
|
|
51
|
+
members = [tag for tag in all_tags if uid in tag.groups]
|
|
52
|
+
|
|
53
|
+
# Step 2: detach each member from this group.
|
|
54
|
+
stale: list[str] = []
|
|
55
|
+
for tag in members:
|
|
56
|
+
new_groups = [g for g in tag.groups if g != uid]
|
|
57
|
+
try:
|
|
58
|
+
client.tags.replace_groups(tag.uid, new_groups, no_notify=True)
|
|
59
|
+
except RtlsError as exc:
|
|
60
|
+
logger.warning("groups.delete: detach from tag %s failed: %s", tag.uid, exc)
|
|
61
|
+
stale.append(tag.uid)
|
|
62
|
+
|
|
63
|
+
# Step 3: delete the group itself.
|
|
64
|
+
try:
|
|
65
|
+
client.groups.delete_raw(uid)
|
|
66
|
+
except RtlsError as exc:
|
|
67
|
+
raise PartialFailureError(
|
|
68
|
+
f"groups.delete: delete_group failed: {exc}",
|
|
69
|
+
completed_steps=completed,
|
|
70
|
+
failed_step="delete_group",
|
|
71
|
+
cause=exc,
|
|
72
|
+
partial_result={"group_uid": uid, "stale_members": stale},
|
|
73
|
+
) from exc
|
|
74
|
+
|
|
75
|
+
if stale:
|
|
76
|
+
# We succeeded in deleting the group, but some members couldn't
|
|
77
|
+
# be detached cleanly — surface that as a soft failure so the
|
|
78
|
+
# caller can reconcile.
|
|
79
|
+
raise PartialFailureError(
|
|
80
|
+
f"groups.delete: deleted group {uid!r} but {len(stale)} tag(s) "
|
|
81
|
+
f"still carry a stale group reference",
|
|
82
|
+
completed_steps=[*completed, "delete_group"],
|
|
83
|
+
failed_step="detach_from_tag",
|
|
84
|
+
cause=RtlsError(f"stale references on tags: {stale}"),
|
|
85
|
+
partial_result={"group_uid": uid, "stale_members": stale},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def add_tags(client: RtlsClient, group_uid: str, tag_uids: list[str]) -> BulkResult[None]:
|
|
90
|
+
"""Add a group to each tag's membership list, continue-on-error.
|
|
91
|
+
|
|
92
|
+
Per-tag: load → append → ``replace_groups``. Already-member tags are
|
|
93
|
+
no-ops (the result counts them as succeeded).
|
|
94
|
+
"""
|
|
95
|
+
result: BulkResult[None] = BulkResult()
|
|
96
|
+
for tag_uid in tag_uids:
|
|
97
|
+
try:
|
|
98
|
+
tag = client.tags.get(tag_uid)
|
|
99
|
+
if group_uid in tag.groups:
|
|
100
|
+
result.succeeded.append(None)
|
|
101
|
+
continue
|
|
102
|
+
client.tags.replace_groups(tag_uid, [*tag.groups, group_uid], no_notify=True)
|
|
103
|
+
result.succeeded.append(None)
|
|
104
|
+
except RtlsError as exc:
|
|
105
|
+
result.failures.append(BulkFailure(error=exc, input=tag_uid, uid=tag_uid))
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def remove_tags(client: RtlsClient, group_uid: str, tag_uids: list[str]) -> BulkResult[None]:
|
|
110
|
+
"""Remove a group from each tag's membership list, continue-on-error."""
|
|
111
|
+
result: BulkResult[None] = BulkResult()
|
|
112
|
+
for tag_uid in tag_uids:
|
|
113
|
+
try:
|
|
114
|
+
tag = client.tags.get(tag_uid)
|
|
115
|
+
if group_uid not in tag.groups:
|
|
116
|
+
result.succeeded.append(None)
|
|
117
|
+
continue
|
|
118
|
+
new_groups = [g for g in tag.groups if g != group_uid]
|
|
119
|
+
client.tags.replace_groups(tag_uid, new_groups, no_notify=True)
|
|
120
|
+
result.succeeded.append(None)
|
|
121
|
+
except RtlsError as exc:
|
|
122
|
+
result.failures.append(BulkFailure(error=exc, input=tag_uid, uid=tag_uid))
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["add_tags", "delete_group", "remove_tags"]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Compound node workflows.
|
|
2
|
+
|
|
3
|
+
Step-name conventions (stable, part of the API contract):
|
|
4
|
+
|
|
5
|
+
- ``release``: ``close_association``, ``release_node``.
|
|
6
|
+
- ``import_``: per-row failures land in ``ImportResult.failures``; no
|
|
7
|
+
shared step names.
|
|
8
|
+
- ``delete``: per-row failures land in ``BulkResult.failures``.
|
|
9
|
+
|
|
10
|
+
The headline behaviour win for M5: ``release`` uses per-call scope
|
|
11
|
+
override (``client._call_with_scope``) so it can target a project
|
|
12
|
+
different from the client's default WITHOUT mutating shared state. The
|
|
13
|
+
JS reference client juggles global axios defaults here and leaks the
|
|
14
|
+
change on error (RESEARCH §4 "Release node and close its association",
|
|
15
|
+
DESIGN §1 snippet 1.11).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from ..errors import PartialFailureError, RtlsError
|
|
25
|
+
from ..models import BulkFailure, BulkResult, ImportResult
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .._client import RtlsClient
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("rtls_sdk.compounds.nodes")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def release_node(
|
|
35
|
+
client: RtlsClient,
|
|
36
|
+
mac_address: str,
|
|
37
|
+
*,
|
|
38
|
+
project_uid: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Close any open association for ``mac_address``, then release.
|
|
41
|
+
|
|
42
|
+
All sub-calls run under the supplied ``project_uid`` (or the
|
|
43
|
+
client's default project if ``None`` is passed). The scope override
|
|
44
|
+
is per-thread and reverts on exit — concurrent calls on the same
|
|
45
|
+
client don't trample each other (the bug the JS reference client
|
|
46
|
+
has).
|
|
47
|
+
|
|
48
|
+
Step names:
|
|
49
|
+
- ``close_association`` (only when an open association exists)
|
|
50
|
+
- ``release_node``
|
|
51
|
+
|
|
52
|
+
Partial-failure rule: if ``close_association`` fails, raise without
|
|
53
|
+
attempting release. If ``release_node`` fails after a successful
|
|
54
|
+
close, raise :class:`PartialFailureError` with
|
|
55
|
+
``completed_steps=["close_association"]`` — the association is now
|
|
56
|
+
closed but the node is still assigned; caller must reconcile.
|
|
57
|
+
"""
|
|
58
|
+
target_project = project_uid or client._auth_state.project_uid
|
|
59
|
+
|
|
60
|
+
with client._call_with_scope(project_uid=target_project):
|
|
61
|
+
# Step 1: find an open association for this MAC.
|
|
62
|
+
open_assoc = None
|
|
63
|
+
try:
|
|
64
|
+
for assoc in client.associations.list():
|
|
65
|
+
if assoc.mac_address == mac_address and assoc.closed_at is None and assoc.uid:
|
|
66
|
+
open_assoc = assoc
|
|
67
|
+
break
|
|
68
|
+
except RtlsError:
|
|
69
|
+
# If we can't list associations, fall through to release —
|
|
70
|
+
# the server will surface any blocking constraint.
|
|
71
|
+
open_assoc = None
|
|
72
|
+
|
|
73
|
+
completed: list[str] = []
|
|
74
|
+
|
|
75
|
+
# Step 2: close the association if one was open.
|
|
76
|
+
if open_assoc is not None:
|
|
77
|
+
try:
|
|
78
|
+
# Pass the Association directly so close() doesn't have
|
|
79
|
+
# to re-list. The server identifies by mac_address /
|
|
80
|
+
# obj_uid in the body, not by the association uid.
|
|
81
|
+
client.associations.close(open_assoc)
|
|
82
|
+
except RtlsError as exc:
|
|
83
|
+
# Re-raise without attempting release — the association
|
|
84
|
+
# didn't close, releasing the node would orphan it.
|
|
85
|
+
raise PartialFailureError(
|
|
86
|
+
f"nodes.release: close_association failed: {exc}",
|
|
87
|
+
completed_steps=completed,
|
|
88
|
+
failed_step="close_association",
|
|
89
|
+
cause=exc,
|
|
90
|
+
) from exc
|
|
91
|
+
completed.append("close_association")
|
|
92
|
+
|
|
93
|
+
# Step 3: release the node.
|
|
94
|
+
try:
|
|
95
|
+
client.nodes.release_raw(mac_address, project_uid=target_project or "")
|
|
96
|
+
except RtlsError as exc:
|
|
97
|
+
raise PartialFailureError(
|
|
98
|
+
f"nodes.release: release_node failed: {exc}",
|
|
99
|
+
completed_steps=completed,
|
|
100
|
+
failed_step="release_node",
|
|
101
|
+
cause=exc,
|
|
102
|
+
) from exc
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def import_nodes(
|
|
106
|
+
client: RtlsClient,
|
|
107
|
+
mac_addresses: list[str],
|
|
108
|
+
*,
|
|
109
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
110
|
+
) -> ImportResult:
|
|
111
|
+
"""Validate MACs server-side, then create each valid row.
|
|
112
|
+
|
|
113
|
+
Short-circuits if any MAC is invalid or duplicate — DESIGN §4.9: the
|
|
114
|
+
server's validation step is treated as a hard gate, and the SDK
|
|
115
|
+
refuses to partially import a batch with known-bad rows.
|
|
116
|
+
|
|
117
|
+
``on_progress(done, total)`` (optional) is invoked after each create
|
|
118
|
+
attempt, succeeded or not.
|
|
119
|
+
"""
|
|
120
|
+
validation = client.nodes.validate(mac_addresses)
|
|
121
|
+
valid_raw = validation.get("valid")
|
|
122
|
+
invalid_raw = validation.get("invalid")
|
|
123
|
+
duplicate_raw = validation.get("duplicate")
|
|
124
|
+
|
|
125
|
+
valid = list(valid_raw) if isinstance(valid_raw, list) else []
|
|
126
|
+
invalid = list(invalid_raw) if isinstance(invalid_raw, list) else []
|
|
127
|
+
duplicate = list(duplicate_raw) if isinstance(duplicate_raw, list) else []
|
|
128
|
+
|
|
129
|
+
# Short-circuit: any invalid or duplicate row means we don't create.
|
|
130
|
+
if invalid or duplicate:
|
|
131
|
+
return ImportResult(
|
|
132
|
+
valid=valid,
|
|
133
|
+
invalid=invalid,
|
|
134
|
+
duplicate=duplicate,
|
|
135
|
+
imported_count=0,
|
|
136
|
+
failures=[],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
failures: list[BulkFailure] = []
|
|
140
|
+
total = len(valid)
|
|
141
|
+
for i, mac in enumerate(valid, start=1):
|
|
142
|
+
try:
|
|
143
|
+
client.nodes.create(mac)
|
|
144
|
+
except RtlsError as exc:
|
|
145
|
+
failures.append(BulkFailure(error=exc, input=mac, uid=mac))
|
|
146
|
+
if on_progress is not None:
|
|
147
|
+
on_progress(i, total)
|
|
148
|
+
|
|
149
|
+
return ImportResult(
|
|
150
|
+
valid=valid,
|
|
151
|
+
invalid=invalid,
|
|
152
|
+
duplicate=duplicate,
|
|
153
|
+
imported_count=len(valid) - len(failures),
|
|
154
|
+
failures=failures,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def delete_nodes(client: RtlsClient, uid_or_uids: str | list[str]) -> BulkResult[None]:
|
|
159
|
+
"""Delete one or many nodes, continue-on-error.
|
|
160
|
+
|
|
161
|
+
Diverges from M3's :meth:`NodesAPI.delete_raw` which is fail-fast.
|
|
162
|
+
The compound :meth:`NodesAPI.delete` is the safer default for bulk
|
|
163
|
+
deletes; the raw variant remains for callers that want a hard fail.
|
|
164
|
+
"""
|
|
165
|
+
uids = [uid_or_uids] if isinstance(uid_or_uids, str) else uid_or_uids
|
|
166
|
+
result: BulkResult[None] = BulkResult()
|
|
167
|
+
for uid in uids:
|
|
168
|
+
try:
|
|
169
|
+
client.nodes.delete_raw(uid)
|
|
170
|
+
result.succeeded.append(None)
|
|
171
|
+
except RtlsError as exc:
|
|
172
|
+
result.failures.append(BulkFailure(error=exc, input=uid, uid=uid))
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = ["delete_nodes", "import_nodes", "release_node"]
|