ngits-drf-iot-client 0.1.0__tar.gz

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.
Files changed (25) hide show
  1. ngits_drf_iot_client-0.1.0/PKG-INFO +85 -0
  2. ngits_drf_iot_client-0.1.0/README.md +66 -0
  3. ngits_drf_iot_client-0.1.0/iot_client/__init__.py +28 -0
  4. ngits_drf_iot_client-0.1.0/iot_client/apps.py +12 -0
  5. ngits_drf_iot_client-0.1.0/iot_client/client.py +87 -0
  6. ngits_drf_iot_client-0.1.0/iot_client/conf.py +26 -0
  7. ngits_drf_iot_client-0.1.0/iot_client/exceptions.py +13 -0
  8. ngits_drf_iot_client-0.1.0/iot_client/identity.py +25 -0
  9. ngits_drf_iot_client-0.1.0/iot_client/mixins.py +42 -0
  10. ngits_drf_iot_client-0.1.0/iot_client/proxy.py +110 -0
  11. ngits_drf_iot_client-0.1.0/iot_client/resources/__init__.py +13 -0
  12. ngits_drf_iot_client-0.1.0/iot_client/resources/alerts.py +41 -0
  13. ngits_drf_iot_client-0.1.0/iot_client/resources/automations.py +71 -0
  14. ngits_drf_iot_client-0.1.0/iot_client/resources/base.py +29 -0
  15. ngits_drf_iot_client-0.1.0/iot_client/resources/commands.py +15 -0
  16. ngits_drf_iot_client-0.1.0/iot_client/schemas.py +255 -0
  17. ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/PKG-INFO +85 -0
  18. ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/SOURCES.txt +23 -0
  19. ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/dependency_links.txt +1 -0
  20. ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/requires.txt +12 -0
  21. ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/top_level.txt +1 -0
  22. ngits_drf_iot_client-0.1.0/pyproject.toml +38 -0
  23. ngits_drf_iot_client-0.1.0/setup.cfg +4 -0
  24. ngits_drf_iot_client-0.1.0/tests/test_client.py +215 -0
  25. ngits_drf_iot_client-0.1.0/tests/test_schemas.py +80 -0
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: ngits-drf-iot-client
3
+ Version: 0.1.0
4
+ Summary: Identity-agnostic Django/DRF client for the NGITS IoT ESB automation & command APIs
5
+ Author-email: "NG IT Services Sp. z o.o." <biuro@ngits.pl>
6
+ License: BSD-3-Clause
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: Django>=4.2
10
+ Requires-Dist: djangorestframework>=3.14
11
+ Requires-Dist: requests>=2.31
12
+ Provides-Extra: schemas
13
+ Requires-Dist: drf-spectacular>=0.27; extra == "schemas"
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
16
+ Requires-Dist: black>=24.0.0; extra == "dev"
17
+ Requires-Dist: isort>=5.13.0; extra == "dev"
18
+ Requires-Dist: drf-spectacular>=0.27; extra == "dev"
19
+
20
+ # ngits-drf-iot-client
21
+
22
+ Identity-agnostic Django/DRF client for the **IoT ESB** automation and command
23
+ APIs. It lets any application (a single-tenant app, a multi-tenant panel, ...)
24
+ act as a thin BFF over the shared IoT platform **without duplicating domain
25
+ logic** — the same way `ngits-drf-cube-proxy` sits in front of analytics.
26
+
27
+ The automation domain (models, evaluation, command dispatch) lives in the IoT
28
+ platform services. This package is only the transport + tenant-scoping glue
29
+ used by the presentation layer.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install ngits-drf-iot-client
35
+ # during local development, before it is published:
36
+ pip install -e ../ngits-drf-iot-client
37
+ ```
38
+
39
+ ## Configure (host `settings.py`)
40
+
41
+ ```python
42
+ IOT_ESB_BASE_URL = "https://iot-esb.example.com" # client appends /api/v1
43
+ IOT_ESB_TOKEN = env("IOT_ESB_TOKEN") # sent as X-Token
44
+ IOT_CLIENT_IDENTITY_RESOLVER = "myapp.iot.resolve_scope"
45
+ # optional:
46
+ IOT_CLIENT_TIMEOUT = 10
47
+ ```
48
+
49
+ The resolver maps an authenticated user to the tenant(s) they may access:
50
+
51
+ ```python
52
+ # myapp/iot.py (single-tenant app)
53
+ def resolve_scope(user):
54
+ tenant = get_tenant_for(user) # your own lookup
55
+ return None if tenant is None else tenant.uuid # None -> 403
56
+
57
+ # multi-tenant panel
58
+ def resolve_scope(user):
59
+ return list(get_tenants_for(user)) # iterable of tenant UUIDs
60
+ ```
61
+
62
+ ## Use in a DRF view
63
+
64
+ ```python
65
+ from rest_framework import viewsets
66
+ from rest_framework.response import Response
67
+ from iot_client import IotClientMixin
68
+
69
+ class AutomationViewSet(IotClientMixin, viewsets.ViewSet):
70
+ def list(self, request):
71
+ client = self.get_iot_client()
72
+ return Response(client.list_automations(tenants=self.get_tenant_scope()))
73
+
74
+ def create(self, request):
75
+ client = self.get_iot_client()
76
+ return Response(client.create_automation(request.data), status=201)
77
+ ```
78
+
79
+ ## Client surface
80
+
81
+ `IotEsbClient`: `list_automations`, `get_automation`, `create_automation`,
82
+ `update_automation`, `delete_automation`, `activate_automation`,
83
+ `deactivate_automation`, `list_rule_types`, `list_actions`, `create_command`,
84
+ `get_command`. All read methods accept a `tenants` scope; errors raise
85
+ `IotClientError(status_code=...)`.
@@ -0,0 +1,66 @@
1
+ # ngits-drf-iot-client
2
+
3
+ Identity-agnostic Django/DRF client for the **IoT ESB** automation and command
4
+ APIs. It lets any application (a single-tenant app, a multi-tenant panel, ...)
5
+ act as a thin BFF over the shared IoT platform **without duplicating domain
6
+ logic** — the same way `ngits-drf-cube-proxy` sits in front of analytics.
7
+
8
+ The automation domain (models, evaluation, command dispatch) lives in the IoT
9
+ platform services. This package is only the transport + tenant-scoping glue
10
+ used by the presentation layer.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install ngits-drf-iot-client
16
+ # during local development, before it is published:
17
+ pip install -e ../ngits-drf-iot-client
18
+ ```
19
+
20
+ ## Configure (host `settings.py`)
21
+
22
+ ```python
23
+ IOT_ESB_BASE_URL = "https://iot-esb.example.com" # client appends /api/v1
24
+ IOT_ESB_TOKEN = env("IOT_ESB_TOKEN") # sent as X-Token
25
+ IOT_CLIENT_IDENTITY_RESOLVER = "myapp.iot.resolve_scope"
26
+ # optional:
27
+ IOT_CLIENT_TIMEOUT = 10
28
+ ```
29
+
30
+ The resolver maps an authenticated user to the tenant(s) they may access:
31
+
32
+ ```python
33
+ # myapp/iot.py (single-tenant app)
34
+ def resolve_scope(user):
35
+ tenant = get_tenant_for(user) # your own lookup
36
+ return None if tenant is None else tenant.uuid # None -> 403
37
+
38
+ # multi-tenant panel
39
+ def resolve_scope(user):
40
+ return list(get_tenants_for(user)) # iterable of tenant UUIDs
41
+ ```
42
+
43
+ ## Use in a DRF view
44
+
45
+ ```python
46
+ from rest_framework import viewsets
47
+ from rest_framework.response import Response
48
+ from iot_client import IotClientMixin
49
+
50
+ class AutomationViewSet(IotClientMixin, viewsets.ViewSet):
51
+ def list(self, request):
52
+ client = self.get_iot_client()
53
+ return Response(client.list_automations(tenants=self.get_tenant_scope()))
54
+
55
+ def create(self, request):
56
+ client = self.get_iot_client()
57
+ return Response(client.create_automation(request.data), status=201)
58
+ ```
59
+
60
+ ## Client surface
61
+
62
+ `IotEsbClient`: `list_automations`, `get_automation`, `create_automation`,
63
+ `update_automation`, `delete_automation`, `activate_automation`,
64
+ `deactivate_automation`, `list_rule_types`, `list_actions`, `create_command`,
65
+ `get_command`. All read methods accept a `tenants` scope; errors raise
66
+ `IotClientError(status_code=...)`.
@@ -0,0 +1,28 @@
1
+ """NGITS DRF IoT client.
2
+
3
+ A thin, identity-agnostic client + DRF helpers for the IoT ESB automation,
4
+ command and alert APIs. The client exposes one sub-client per resource
5
+ (``client.automations`` / ``client.commands`` / ``client.alerts``), and
6
+ ``IotResourceProxyViewSet`` turns a host module into a thin BFF. Mirrors
7
+ ``ngits-drf-cube-proxy``: applications (a single-tenant app, a multi-tenant
8
+ panel, ...) install this package and wire their tenant-scope resolver via
9
+ ``IOT_CLIENT_IDENTITY_RESOLVER`` — the package itself knows nothing about a
10
+ particular application's identity model.
11
+ """
12
+
13
+ from iot_client.client import IotEsbClient
14
+ from iot_client.exceptions import IotClientError, IotClientImproperlyConfigured
15
+ from iot_client.identity import resolve_scope
16
+ from iot_client.mixins import IotClientMixin
17
+ from iot_client.proxy import IotResourceProxyViewSet
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "IotEsbClient",
23
+ "IotClientError",
24
+ "IotClientImproperlyConfigured",
25
+ "resolve_scope",
26
+ "IotClientMixin",
27
+ "IotResourceProxyViewSet",
28
+ ]
@@ -0,0 +1,12 @@
1
+ """Optional Django AppConfig.
2
+
3
+ The client works as a plain library (just reads settings), but it can be added
4
+ to ``INSTALLED_APPS`` for discoverability and future checks/management commands.
5
+ """
6
+
7
+ from django.apps import AppConfig
8
+
9
+
10
+ class IotClientConfig(AppConfig):
11
+ name = "iot_client"
12
+ verbose_name = "NGITS IoT ESB client"
@@ -0,0 +1,87 @@
1
+ """HTTP transport for the IoT ESB, exposing one sub-client per resource.
2
+
3
+ Pure transport: no Django request/user awareness (that lives in
4
+ ``iot_client.identity`` / ``iot_client.mixins`` / ``iot_client.proxy``), so it is
5
+ trivially unit-testable. Resources are reached as namespaced sub-clients::
6
+
7
+ client = IotEsbClient()
8
+ client.automations.list(tenants=...)
9
+ client.commands.create(payload)
10
+ client.alerts.list(tenants=...)
11
+ """
12
+
13
+ from functools import cached_property
14
+ from typing import Any, Dict, Optional
15
+
16
+ import requests
17
+
18
+ from iot_client.conf import get_setting
19
+ from iot_client.exceptions import IotClientError
20
+ from iot_client.resources import (
21
+ AlertsResource,
22
+ AutomationsResource,
23
+ CommandsResource,
24
+ )
25
+
26
+
27
+ class IotEsbClient:
28
+ """Client for ``{IOT_ESB_BASE_URL}/api/v1`` with per-resource sub-clients."""
29
+
30
+ def __init__(
31
+ self,
32
+ base_url: Optional[str] = None,
33
+ token: Optional[str] = None,
34
+ timeout: Optional[int] = None,
35
+ ):
36
+ self.base_url = (base_url or get_setting("IOT_ESB_BASE_URL")).rstrip("/")
37
+ self.token = token or get_setting("IOT_ESB_TOKEN")
38
+ self.timeout = timeout or get_setting("IOT_CLIENT_TIMEOUT", 10)
39
+
40
+ # -- resource sub-clients ---------------------------------------------
41
+
42
+ @cached_property
43
+ def automations(self) -> AutomationsResource:
44
+ return AutomationsResource(self)
45
+
46
+ @cached_property
47
+ def commands(self) -> CommandsResource:
48
+ return CommandsResource(self)
49
+
50
+ @cached_property
51
+ def alerts(self) -> AlertsResource:
52
+ return AlertsResource(self)
53
+
54
+ # -- transport ---------------------------------------------------------
55
+
56
+ @property
57
+ def _headers(self) -> Dict[str, str]:
58
+ return {"Content-Type": "application/json", "X-Token": str(self.token)}
59
+
60
+ def _url(self, path: str) -> str:
61
+ return f"{self.base_url}/api/v1{path}"
62
+
63
+ def _request(
64
+ self,
65
+ method: str,
66
+ path: str,
67
+ *,
68
+ params: Optional[Dict[str, Any]] = None,
69
+ json: Optional[Dict[str, Any]] = None,
70
+ ) -> Any:
71
+ try:
72
+ response = requests.request(
73
+ method,
74
+ self._url(path),
75
+ params=params,
76
+ json=json,
77
+ headers=self._headers,
78
+ timeout=self.timeout,
79
+ )
80
+ except requests.RequestException as exc:
81
+ raise IotClientError(f"IoT ESB request failed: {exc}") from exc
82
+
83
+ if response.status_code >= 400:
84
+ raise IotClientError(response.text, response.status_code)
85
+ if response.status_code == 204 or not response.content:
86
+ return None
87
+ return response.json()
@@ -0,0 +1,26 @@
1
+ """Settings access for the IoT client.
2
+
3
+ Recognized Django settings:
4
+ - ``IOT_ESB_BASE_URL`` (required) base URL of the IoT ESB, e.g.
5
+ ``https://iot-esb.example.com``. The client appends ``/api/v1``.
6
+ - ``IOT_ESB_TOKEN`` (required) token sent in the ``X-Token`` header.
7
+ - ``IOT_CLIENT_IDENTITY_RESOLVER`` (optional) dotted path to a callable
8
+ ``resolver(user) -> tenant scope`` (see ``iot_client.identity``).
9
+ - ``IOT_CLIENT_TIMEOUT`` (optional, default 10) request timeout in seconds.
10
+ """
11
+
12
+ from django.conf import settings
13
+
14
+ from iot_client.exceptions import IotClientImproperlyConfigured
15
+
16
+ _SENTINEL = object()
17
+
18
+
19
+ def get_setting(name: str, default=_SENTINEL):
20
+ """Return a Django setting, or raise if required and missing."""
21
+ value = getattr(settings, name, _SENTINEL)
22
+ if value is _SENTINEL:
23
+ if default is _SENTINEL:
24
+ raise IotClientImproperlyConfigured(f"Missing required setting: {name}")
25
+ return default
26
+ return value
@@ -0,0 +1,13 @@
1
+ """Exceptions raised by the IoT client."""
2
+
3
+
4
+ class IotClientError(Exception):
5
+ """An error returned by (or while reaching) the IoT ESB API."""
6
+
7
+ def __init__(self, message: str, status_code: int = 0):
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+
11
+
12
+ class IotClientImproperlyConfigured(Exception):
13
+ """A required Django setting for the IoT client is missing."""
@@ -0,0 +1,25 @@
1
+ """Tenant-scope resolution.
2
+
3
+ The package is identity-agnostic: the host application supplies a callable via
4
+ ``IOT_CLIENT_IDENTITY_RESOLVER`` (a dotted path) that maps an authenticated user
5
+ to the tenant(s) they may access. The resolver returns:
6
+
7
+ - ``None`` -> no access (callers should treat as 403),
8
+ - a single ``UUID``/str -> one tenant (e.g. a single-tenant app),
9
+ - an iterable of ids -> several tenants (e.g. a multi-tenant panel).
10
+
11
+ This mirrors ``ngits-drf-cube-proxy``'s ``CUBE_PROXY_IDENTITY_RESOLVER``.
12
+ """
13
+
14
+ from django.utils.module_loading import import_string
15
+
16
+ from iot_client.conf import get_setting
17
+
18
+
19
+ def resolve_scope(user):
20
+ """Resolve the tenant scope for ``user`` using the configured resolver."""
21
+ dotted_path = get_setting("IOT_CLIENT_IDENTITY_RESOLVER", None)
22
+ if not dotted_path:
23
+ return None
24
+ resolver = import_string(dotted_path)
25
+ return resolver(user)
@@ -0,0 +1,42 @@
1
+ """DRF helpers for building a thin BFF over the IoT ESB.
2
+
3
+ Usage in a host application::
4
+
5
+ class AutomationViewSet(IotClientMixin, viewsets.ViewSet):
6
+ def list(self, request):
7
+ client = self.get_iot_client()
8
+ return Response(client.list_automations(tenants=self.get_tenant_scope()))
9
+ """
10
+
11
+ from typing import List
12
+
13
+ from rest_framework.exceptions import PermissionDenied
14
+
15
+ from iot_client.client import IotEsbClient
16
+ from iot_client.identity import resolve_scope
17
+
18
+
19
+ class IotClientMixin:
20
+ """Provides an ESB client and the request user's tenant scope."""
21
+
22
+ iot_client_class = IotEsbClient
23
+
24
+ def get_iot_client(self) -> IotEsbClient:
25
+ return self.iot_client_class()
26
+
27
+ def get_tenant_scope(self) -> List:
28
+ """Return the user's tenant scope as a list, or raise 403.
29
+
30
+ The host application's resolver (``IOT_CLIENT_IDENTITY_RESOLVER``)
31
+ decides what each user may see; a single-tenant app returns one id, a
32
+ multi-tenant panel returns several.
33
+ """
34
+ scope = resolve_scope(self.request.user)
35
+ if scope is None:
36
+ raise PermissionDenied("No IoT tenant scope for this user.")
37
+ if isinstance(scope, (list, tuple, set)):
38
+ scope_list = list(scope)
39
+ if not scope_list:
40
+ raise PermissionDenied("Empty IoT tenant scope for this user.")
41
+ return scope_list
42
+ return [scope]
@@ -0,0 +1,110 @@
1
+ """Generic DRF proxy viewset over an IoT ESB resource sub-client.
2
+
3
+ Turns a host-application module into a thin BFF: subclass, set ``resource_name``
4
+ to a sub-client attribute on :class:`~iot_client.client.IotEsbClient` (e.g.
5
+ ``"automations"``), and the standard list / retrieve / create / partial_update /
6
+ destroy actions are wired — tenant-scoped, with ESB errors mapped to HTTP. Add
7
+ ``@action`` methods for resource-specific extras (e.g. activate, catalog).
8
+
9
+ Example::
10
+
11
+ from rest_framework.decorators import action
12
+ from rest_framework.response import Response
13
+ from iot_client import IotResourceProxyViewSet
14
+
15
+ class AutomationViewSet(IotResourceProxyViewSet):
16
+ resource_name = "automations"
17
+ lookup_value_regex = "[0-9a-f-]{36}"
18
+
19
+ @action(detail=True, methods=["post"])
20
+ def activate(self, request, pk=None):
21
+ return self.proxy(lambda r: r.activate(pk, tenants=self.get_tenant_scope()))
22
+ """
23
+
24
+ from rest_framework import status, viewsets
25
+ from rest_framework.response import Response
26
+
27
+ from iot_client.exceptions import IotClientError
28
+ from iot_client.mixins import IotClientMixin
29
+
30
+
31
+ class IotResourceProxyViewSet(IotClientMixin, viewsets.ViewSet):
32
+ """Standard CRUD proxy to ``IotEsbClient.<resource_name>``."""
33
+
34
+ #: attribute name of the sub-client on IotEsbClient, e.g. "automations".
35
+ resource_name: str = None
36
+ #: optional DRF serializers used to validate create / update bodies.
37
+ create_serializer_class = None
38
+ update_serializer_class = None
39
+
40
+ def get_resource(self):
41
+ if not self.resource_name:
42
+ raise NotImplementedError("Set `resource_name` on the viewset.")
43
+ return getattr(self.get_iot_client(), self.resource_name)
44
+
45
+ # -- hooks -------------------------------------------------------------
46
+
47
+ def get_list_filters(self) -> dict:
48
+ """Extra query filters for ``list`` (override per module)."""
49
+ return {}
50
+
51
+ def prepare_create_payload(self, data: dict) -> dict:
52
+ """Adapt the validated/raw create body before sending (override)."""
53
+ return dict(data)
54
+
55
+ # -- standard actions --------------------------------------------------
56
+
57
+ def list(self, request):
58
+ return self.proxy(
59
+ lambda r: r.list(tenants=self.get_tenant_scope(), **self.get_list_filters())
60
+ )
61
+
62
+ def retrieve(self, request, pk=None):
63
+ return self.proxy(lambda r: r.get(pk, tenants=self.get_tenant_scope()))
64
+
65
+ def create(self, request):
66
+ payload = self.prepare_create_payload(
67
+ self._validate(self.create_serializer_class, request.data)
68
+ )
69
+ return self.proxy(lambda r: r.create(payload), success=status.HTTP_201_CREATED)
70
+
71
+ def partial_update(self, request, pk=None):
72
+ payload = self._validate(self.update_serializer_class, request.data)
73
+ return self.proxy(
74
+ lambda r: r.update(pk, payload, tenants=self.get_tenant_scope())
75
+ )
76
+
77
+ def destroy(self, request, pk=None):
78
+ return self.proxy(
79
+ lambda r: r.delete(pk, tenants=self.get_tenant_scope()),
80
+ success=status.HTTP_204_NO_CONTENT,
81
+ )
82
+
83
+ # -- helpers -----------------------------------------------------------
84
+
85
+ def proxy(self, call, success=status.HTTP_200_OK) -> Response:
86
+ """Run a sub-client call, mapping ``IotClientError`` to an HTTP error."""
87
+ try:
88
+ result = call(self.get_resource())
89
+ except IotClientError as exc:
90
+ return self._esb_error(exc)
91
+ if success == status.HTTP_204_NO_CONTENT:
92
+ return Response(status=success)
93
+ return Response(result, status=success)
94
+
95
+ @staticmethod
96
+ def _validate(serializer_class, data) -> dict:
97
+ if serializer_class is None:
98
+ return dict(data)
99
+ serializer = serializer_class(data=data)
100
+ serializer.is_valid(raise_exception=True)
101
+ return serializer.validated_data
102
+
103
+ @staticmethod
104
+ def _esb_error(exc: IotClientError) -> Response:
105
+ code = (
106
+ status.HTTP_404_NOT_FOUND
107
+ if exc.status_code == 404
108
+ else status.HTTP_502_BAD_GATEWAY
109
+ )
110
+ return Response({"detail": str(exc)}, status=code)
@@ -0,0 +1,13 @@
1
+ """ESB resource sub-clients."""
2
+
3
+ from iot_client.resources.alerts import AlertsResource
4
+ from iot_client.resources.automations import AutomationsResource
5
+ from iot_client.resources.base import BaseResource
6
+ from iot_client.resources.commands import CommandsResource
7
+
8
+ __all__ = [
9
+ "BaseResource",
10
+ "AlertsResource",
11
+ "AutomationsResource",
12
+ "CommandsResource",
13
+ ]
@@ -0,0 +1,41 @@
1
+ """Alert-definition resource sub-client (``/api/v1/alerts/definitions``).
2
+
3
+ The ESB alert-definitions endpoint scopes list by a single ``tenant`` query
4
+ param, so this resource uses the first tenant of the scope. Cross-tenant
5
+ enumeration (admin "list all") is the host application's responsibility — it owns
6
+ the tenant catalog — so it loops :meth:`list` over the tenant ids it resolves.
7
+ """
8
+
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from iot_client.resources.base import BaseResource, TenantScope
12
+
13
+ _BASE = "/alerts/definitions"
14
+
15
+
16
+ class AlertsResource(BaseResource):
17
+ """CRUD for alert definitions."""
18
+
19
+ def list(
20
+ self, tenants: TenantScope = None, meter_id: Optional[str] = None
21
+ ) -> List[Dict[str, Any]]:
22
+ params: Dict[str, Any] = {}
23
+ if tenants:
24
+ params["tenant"] = str(list(tenants)[0])
25
+ if meter_id:
26
+ params["meter_id"] = meter_id
27
+ return self._request("GET", _BASE, params=params)
28
+
29
+ def get(self, definition_id, tenants: TenantScope = None) -> Dict[str, Any]:
30
+ return self._request("GET", f"{_BASE}/{definition_id}")
31
+
32
+ def create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
33
+ return self._request("POST", _BASE, json=payload)
34
+
35
+ def update(
36
+ self, definition_id, payload: Dict[str, Any], tenants: TenantScope = None
37
+ ) -> Dict[str, Any]:
38
+ return self._request("PATCH", f"{_BASE}/{definition_id}", json=payload)
39
+
40
+ def delete(self, definition_id, tenants: TenantScope = None) -> None:
41
+ return self._request("DELETE", f"{_BASE}/{definition_id}")
@@ -0,0 +1,71 @@
1
+ """Automation resource sub-client (``/api/v1/automations``)."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+ from uuid import UUID
5
+
6
+ from iot_client.resources.base import BaseResource, TenantScope, scope_params
7
+
8
+
9
+ class AutomationsResource(BaseResource):
10
+ """CRUD + lifecycle + wizard catalog for automations."""
11
+
12
+ # -- uniform CRUD (consumed by IotResourceProxyViewSet) ----------------
13
+
14
+ def list(self, tenants: TenantScope = None, **filters: Any) -> Dict[str, Any]:
15
+ params = {k: v for k, v in filters.items() if v is not None}
16
+ params.update(scope_params(tenants))
17
+ return self._request("GET", "/automations", params=params)
18
+
19
+ def get(self, automation_id, tenants: TenantScope = None) -> Dict[str, Any]:
20
+ return self._request(
21
+ "GET", f"/automations/{automation_id}", params=scope_params(tenants)
22
+ )
23
+
24
+ def create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
25
+ return self._request("POST", "/automations", json=payload)
26
+
27
+ def update(
28
+ self, automation_id, payload: Dict[str, Any], tenants: TenantScope = None
29
+ ) -> Dict[str, Any]:
30
+ return self._request(
31
+ "PATCH",
32
+ f"/automations/{automation_id}",
33
+ params=scope_params(tenants),
34
+ json=payload,
35
+ )
36
+
37
+ def delete(self, automation_id, tenants: TenantScope = None) -> None:
38
+ return self._request(
39
+ "DELETE",
40
+ f"/automations/{automation_id}",
41
+ params=scope_params(tenants),
42
+ )
43
+
44
+ # -- lifecycle ---------------------------------------------------------
45
+
46
+ def activate(self, automation_id, tenants: TenantScope = None) -> Dict[str, Any]:
47
+ return self._request(
48
+ "POST",
49
+ f"/automations/{automation_id}/activate",
50
+ params=scope_params(tenants),
51
+ )
52
+
53
+ def deactivate(self, automation_id, tenants: TenantScope = None) -> Dict[str, Any]:
54
+ return self._request(
55
+ "POST",
56
+ f"/automations/{automation_id}/deactivate",
57
+ params=scope_params(tenants),
58
+ )
59
+
60
+ # -- wizard catalog ----------------------------------------------------
61
+
62
+ def rule_types(self) -> List[Dict[str, Any]]:
63
+ return self._request("GET", "/automations/catalog/rule-types")
64
+
65
+ def actions(
66
+ self, tenant: UUID, device_id: Optional[str] = None
67
+ ) -> List[Dict[str, Any]]:
68
+ params: Dict[str, Any] = {"tenant": str(tenant)}
69
+ if device_id:
70
+ params["device_id"] = device_id
71
+ return self._request("GET", "/automations/catalog/actions", params=params)
@@ -0,0 +1,29 @@
1
+ """Base class and helpers for ESB resource sub-clients.
2
+
3
+ A resource sub-client groups the operations of one ESB resource (automations,
4
+ commands, alert definitions, ...) and shares the transport of its parent
5
+ :class:`~iot_client.client.IotEsbClient`. Adding a new ESB resource = a new
6
+ ``BaseResource`` subclass + a property on the client; nothing else changes.
7
+ """
8
+
9
+ from typing import Any, Dict, Optional, Sequence
10
+ from uuid import UUID
11
+
12
+ TenantScope = Optional[Sequence[UUID]]
13
+
14
+
15
+ def scope_params(tenants: TenantScope) -> Dict[str, Any]:
16
+ """Render a tenant scope as repeatable ``tenants`` query params."""
17
+ if not tenants:
18
+ return {}
19
+ return {"tenants": [str(t) for t in tenants]}
20
+
21
+
22
+ class BaseResource:
23
+ """Holds a reference to the client and delegates HTTP to its transport."""
24
+
25
+ def __init__(self, client):
26
+ self._client = client
27
+
28
+ def _request(self, method: str, path: str, **kwargs):
29
+ return self._client._request(method, path, **kwargs)
@@ -0,0 +1,15 @@
1
+ """Command resource sub-client (``/api/v1/iot/commands``)."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ from iot_client.resources.base import BaseResource
6
+
7
+
8
+ class CommandsResource(BaseResource):
9
+ """Device command dispatch + status."""
10
+
11
+ def create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
12
+ return self._request("POST", "/iot/commands", json=payload)
13
+
14
+ def get(self, command_id) -> Dict[str, Any]:
15
+ return self._request("GET", f"/iot/commands/{command_id}")