wizata-dsapi 2.2.0.dev1__tar.gz → 2.2.0.dev3__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 (70) hide show
  1. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/PKG-INFO +1 -1
  2. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/__init__.py +5 -0
  3. wizata_dsapi-2.2.0.dev3/wizata_dsapi/connection.py +299 -0
  4. wizata_dsapi-2.2.0.dev3/wizata_dsapi/connection_types.py +325 -0
  5. wizata_dsapi-2.2.0.dev3/wizata_dsapi/product.py +210 -0
  6. wizata_dsapi-2.2.0.dev3/wizata_dsapi/version.py +1 -0
  7. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/wizata_dsapi_client.py +137 -0
  8. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi.egg-info/PKG-INFO +1 -1
  9. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi.egg-info/SOURCES.txt +3 -0
  10. wizata_dsapi-2.2.0.dev1/wizata_dsapi/version.py +0 -1
  11. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/LICENSE.txt +0 -0
  12. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/README.rst +0 -0
  13. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/setup.cfg +0 -0
  14. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/setup.py +0 -0
  15. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/api_config.py +0 -0
  16. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/api_dto.py +0 -0
  17. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/api_interface.py +0 -0
  18. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/bucket.py +0 -0
  19. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/business_label.py +0 -0
  20. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/context.py +0 -0
  21. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/dashboard.py +0 -0
  22. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/dataframe_toolkit.py +0 -0
  23. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/datapoint.py +0 -0
  24. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/datastore.py +0 -0
  25. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/deployment.py +0 -0
  26. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/ds_dataframe.py +0 -0
  27. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/dsapi_json_encoder.py +0 -0
  28. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/edge_config.py +0 -0
  29. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/edge_device.py +0 -0
  30. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/edge_module.py +0 -0
  31. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/evaluation.py +0 -0
  32. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/execution.py +0 -0
  33. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/execution_log.py +0 -0
  34. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/experiment.py +0 -0
  35. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/graylog_log.py +0 -0
  36. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/group_system.py +0 -0
  37. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/ilogger.py +0 -0
  38. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/insight.py +0 -0
  39. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/mlmodel.py +0 -0
  40. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/mobile_asset.py +0 -0
  41. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/model_toolkit.py +0 -0
  42. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/models/__init__.py +0 -0
  43. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/models/common.py +0 -0
  44. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/notification.py +0 -0
  45. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/paged_query_result.py +0 -0
  46. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/pipeline.py +0 -0
  47. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/pipeline_image.py +0 -0
  48. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/plot.py +0 -0
  49. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/plots/__init__.py +0 -0
  50. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/plots/common.py +0 -0
  51. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/plots/theme.py +0 -0
  52. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/request.py +0 -0
  53. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/script.py +0 -0
  54. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/scripts/__init__.py +0 -0
  55. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/scripts/common.py +0 -0
  56. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/search.py +0 -0
  57. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/solution_component.py +0 -0
  58. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/streamlit_utils.py +0 -0
  59. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/template.py +0 -0
  60. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/template_config.py +0 -0
  61. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/trigger.py +0 -0
  62. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/twin.py +0 -0
  63. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/twinregistration.py +0 -0
  64. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/user.py +0 -0
  65. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/wizard_function.py +0 -0
  66. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/wizard_request.py +0 -0
  67. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi/words.py +0 -0
  68. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi.egg-info/dependency_links.txt +0 -0
  69. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi.egg-info/requires.txt +0 -0
  70. {wizata_dsapi-2.2.0.dev1 → wizata_dsapi-2.2.0.dev3}/wizata_dsapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wizata_dsapi
3
- Version: 2.2.0.dev1
3
+ Version: 2.2.0.dev3
4
4
  Summary: Wizata Data Science Toolkit
5
5
  Author: Wizata S.A.
6
6
  Author-email: info@wizata.com
@@ -61,6 +61,11 @@ from .notification import NotificationScope, DeviceToken, AlertRule, AlertActive
61
61
  from .edge_device import EdgeDevice, EdgeDeviceStatus, EdgeDeviceType
62
62
  from .edge_module import EdgeModule, EdgeModuleType, WIZATA_MODULES
63
63
  from .edge_config import EdgeConsumer, EdgeWriter, EdgeTrigger
64
+ from .connection import Connection, ConnectionMapping, ConnectionDirection, TransformKind, \
65
+ EdgeDeviceKey, EdgeDeviceKeyStatus
66
+ from .connection_types import ConnectionType, CONNECTION_TYPES, TRANSFORM_SCHEMAS, get_connection_type, \
67
+ list_connection_types
68
+ from .product import ProductKind, Product, ProductComponent, BUILTIN_PRODUCT_KINDS, is_valid_product_key
64
69
  from .dashboard import Dashboard, DashboardType
65
70
  from .mobile_asset import MobileAssetTile, MobileAssetTileType, MobileAssetConfig
66
71
  from .graylog_log import GraylogLog
@@ -0,0 +1,299 @@
1
+ """
2
+ Edge connection DTOs (DEV-6013).
3
+
4
+ An edge connector used to be one untyped dict in the device's ``consumers.json``,
5
+ credentials inline. It is now split in two entities, the same for every type:
6
+
7
+ Connection — how to reach a source or a target (endpoint, credentials, certificates),
8
+ bound to one edge device. ``config`` is clear; ``secrets`` holds one sealed envelope
9
+ per secret field, readable by the owning edge only.
10
+
11
+ ConnectionMapping — one thing to read or write through a connection (an OPC node, an
12
+ MQTT topic, a Modbus register, an S7 block entry, a queue). ``address`` and
13
+ ``options`` are shaped by the connection type (see ``connection_types``); the other
14
+ fields are common to every type.
15
+
16
+ EdgeDeviceKey — the public half of the key pair an edge generated for itself. Secrets
17
+ are sealed for the edge's active key; rotating adds a row and retires the previous.
18
+ """
19
+
20
+ from datetime import datetime, timezone
21
+ from enum import Enum
22
+ from typing import Optional
23
+
24
+
25
+ class ConnectionDirection(str, Enum):
26
+ READ = "read"
27
+ WRITE = "write"
28
+
29
+
30
+ class TransformKind(str, Enum):
31
+ # value -> value, a python expression on ``x`` (e.g. ``"x / 100"``)
32
+ EXPRESSION = "expression"
33
+ # payload -> list of messages, ``{"filepath": ..., "function_name": ...}``
34
+ FUNCTION = "function"
35
+
36
+
37
+ class EdgeDeviceKeyStatus(str, Enum):
38
+ ACTIVE = "active"
39
+ RETIRED = "retired"
40
+
41
+
42
+ def _ts_out(value: Optional[datetime]) -> Optional[int]:
43
+ return int(value.timestamp() * 1000) if value is not None else None
44
+
45
+
46
+ def _ts_in(value) -> Optional[datetime]:
47
+ if value is None:
48
+ return None
49
+ if isinstance(value, datetime):
50
+ return value
51
+ return datetime.fromtimestamp(value / 1000, tz=timezone.utc)
52
+
53
+
54
+ class Connection:
55
+ """
56
+ Connection entity stored in PostgreSQL ``"Connection"`` table. PK ``id`` (UUID).
57
+
58
+ - ``key``: unique user-facing identifier, also the consumer id on the edge.
59
+ - ``connection_type``: a name from ``connection_types.CONNECTION_TYPES``.
60
+ - ``edge_device_id``: the edge that owns it and alone can read its secrets.
61
+ - ``config``: clear parameters, validated against the type's config schema.
62
+ - ``secrets``: ``{field: envelope}`` once stored. On the write path it carries
63
+ plaintext instead — ``{field: "value"}`` to set, ``{field: None}`` to clear;
64
+ fields left out keep their current value.
65
+ """
66
+
67
+ def __init__(self):
68
+ self.id: Optional[str] = None
69
+ self.key: Optional[str] = None
70
+ self.name: Optional[str] = None
71
+ self.connection_type: Optional[str] = None
72
+ self.edge_device_id: Optional[str] = None
73
+ self.config: dict = {}
74
+ self.secrets: dict = {}
75
+ self.created_at: Optional[datetime] = None
76
+ self.updated_at: Optional[datetime] = None
77
+
78
+ def secrets_set(self) -> list:
79
+ """Names of the secret fields holding a value."""
80
+ return sorted(k for k, v in (self.secrets or {}).items() if v is not None)
81
+
82
+ def to_dict(self, include_secrets: bool = False, active_key_id: str = None) -> dict:
83
+ """
84
+ :param include_secrets: emit the sealed envelopes (persistence and edge
85
+ rendering only). Never set it on a response to a user: the default
86
+ shows which fields are set, not what they hold.
87
+ :param active_key_id: fingerprint of the edge's active key. When given, the
88
+ public form also lists the secrets sealed for another key — the edge
89
+ can no longer open those and they must be entered again.
90
+ """
91
+ obj = {}
92
+ if self.id is not None:
93
+ obj["id"] = str(self.id)
94
+ if self.key is not None:
95
+ obj["key"] = self.key
96
+ if self.name is not None:
97
+ obj["name"] = self.name
98
+ if self.connection_type is not None:
99
+ obj["connectionType"] = self.connection_type
100
+ if self.edge_device_id is not None:
101
+ obj["edgeDeviceId"] = self.edge_device_id
102
+ obj["config"] = dict(self.config or {})
103
+ if include_secrets:
104
+ obj["secrets"] = dict(self.secrets or {})
105
+ else:
106
+ obj["secretsSet"] = self.secrets_set()
107
+ if active_key_id is not None:
108
+ obj["secretsStale"] = sorted(
109
+ k for k, v in (self.secrets or {}).items()
110
+ if isinstance(v, dict) and v.get("kid") != active_key_id
111
+ )
112
+ if self.created_at is not None:
113
+ obj["createdAt"] = _ts_out(self.created_at)
114
+ if self.updated_at is not None:
115
+ obj["updatedAt"] = _ts_out(self.updated_at)
116
+ return obj
117
+
118
+ @classmethod
119
+ def from_dict(cls, data: dict) -> "Connection":
120
+ obj = cls()
121
+ obj.id = data.get("id")
122
+ obj.key = data.get("key")
123
+ obj.name = data.get("name")
124
+ obj.connection_type = data.get("connectionType") or data.get("connection_type")
125
+ obj.edge_device_id = data.get("edgeDeviceId") or data.get("edge_device_id")
126
+ obj.config = data.get("config") or {}
127
+ obj.secrets = data.get("secrets") or {}
128
+ obj.created_at = _ts_in(data.get("createdAt") or data.get("created_at"))
129
+ obj.updated_at = _ts_in(data.get("updatedAt") or data.get("updated_at"))
130
+ return obj
131
+
132
+ def api_id(self) -> str:
133
+ return str(self.id) if self.id else None
134
+
135
+ def api_key(self) -> str:
136
+ return self.key
137
+
138
+ def set_id(self, value):
139
+ self.id = str(value)
140
+
141
+ def __repr__(self) -> str:
142
+ # secrets deliberately left out, even sealed
143
+ return (f"Connection(id={self.id!r}, key={self.key!r}, "
144
+ f"type={self.connection_type!r}, edge={self.edge_device_id!r})")
145
+
146
+
147
+ class ConnectionMapping:
148
+ """
149
+ ConnectionMapping entity stored in PostgreSQL ``"ConnectionMapping"`` table.
150
+ PK ``id`` (UUID). FK ``connection_id`` → ``Connection.id``, cascade on delete.
151
+
152
+ - ``direction``: READ (source → platform) or WRITE (platform → target).
153
+ - ``address``: where, shaped by the type (``{"node_id": ...}``, ``{"topic": ...}``).
154
+ - ``hardware_id``: the datapoint. Optional where the source names it (OPC browse
155
+ with ``children``, a topic function fanning out to many datapoints).
156
+ - ``poll_interval_ms``: polling cadence for polled types; ignored when pushed.
157
+ - ``transform``: ``{"kind": "expression", "expression": "x / 10"}`` or
158
+ ``{"kind": "function", "filepath": ..., "function_name": ...}``.
159
+ - ``options``: type-specific tuning (``qos``, ``batch_size``, ``pack``...).
160
+ """
161
+
162
+ def __init__(self):
163
+ self.id: Optional[str] = None
164
+ self.connection_id: Optional[str] = None
165
+ self.direction: ConnectionDirection = ConnectionDirection.READ
166
+ self.address: dict = {}
167
+ self.hardware_id: Optional[str] = None
168
+ self.poll_interval_ms: Optional[int] = None
169
+ self.transform: Optional[dict] = None
170
+ self.options: dict = {}
171
+ self.created_at: Optional[datetime] = None
172
+ self.updated_at: Optional[datetime] = None
173
+
174
+ def to_dict(self) -> dict:
175
+ obj = {}
176
+ if self.id is not None:
177
+ obj["id"] = str(self.id)
178
+ if self.connection_id is not None:
179
+ obj["connectionId"] = str(self.connection_id)
180
+ obj["direction"] = (self.direction.value
181
+ if isinstance(self.direction, ConnectionDirection)
182
+ else self.direction)
183
+ obj["address"] = dict(self.address or {})
184
+ if self.hardware_id is not None:
185
+ obj["hardwareId"] = self.hardware_id
186
+ if self.poll_interval_ms is not None:
187
+ obj["pollIntervalMs"] = self.poll_interval_ms
188
+ if self.transform is not None:
189
+ obj["transform"] = dict(self.transform)
190
+ obj["options"] = dict(self.options or {})
191
+ if self.created_at is not None:
192
+ obj["createdAt"] = _ts_out(self.created_at)
193
+ if self.updated_at is not None:
194
+ obj["updatedAt"] = _ts_out(self.updated_at)
195
+ return obj
196
+
197
+ @classmethod
198
+ def from_dict(cls, data: dict) -> "ConnectionMapping":
199
+ obj = cls()
200
+ obj.id = data.get("id")
201
+ obj.connection_id = data.get("connectionId") or data.get("connection_id")
202
+ raw_direction = data.get("direction")
203
+ if raw_direction is not None:
204
+ obj.direction = ConnectionDirection(str(raw_direction).lower())
205
+ obj.address = data.get("address") or {}
206
+ obj.hardware_id = data.get("hardwareId") or data.get("hardware_id")
207
+ poll = data.get("pollIntervalMs", data.get("poll_interval_ms"))
208
+ obj.poll_interval_ms = int(poll) if poll is not None else None
209
+ obj.transform = data.get("transform")
210
+ obj.options = data.get("options") or {}
211
+ obj.created_at = _ts_in(data.get("createdAt") or data.get("created_at"))
212
+ obj.updated_at = _ts_in(data.get("updatedAt") or data.get("updated_at"))
213
+ return obj
214
+
215
+ def api_id(self) -> str:
216
+ return str(self.id) if self.id else None
217
+
218
+ def api_key(self) -> str:
219
+ return self.api_id()
220
+
221
+ def set_id(self, value):
222
+ self.id = str(value)
223
+
224
+ def __repr__(self) -> str:
225
+ return (f"ConnectionMapping(id={self.id!r}, connection={self.connection_id!r}, "
226
+ f"direction={self.direction!r}, hardware_id={self.hardware_id!r})")
227
+
228
+
229
+ class EdgeDeviceKey:
230
+ """
231
+ EdgeDeviceKey entity stored in PostgreSQL ``"EdgeDeviceKey"`` table. PK ``id``.
232
+
233
+ The edge generates the pair and registers only the public half; the private half
234
+ never leaves the device, and the platform holds no key that could open a secret.
235
+ At most one ACTIVE row per ``edge_device_id``.
236
+
237
+ - ``public_key``: PEM (SubjectPublicKeyInfo).
238
+ - ``algorithm``: e.g. ``"x25519"``.
239
+ - ``fingerprint``: sha256 of the raw public key, hex — the ``kid`` envelopes carry.
240
+ """
241
+
242
+ def __init__(self):
243
+ self.id: Optional[str] = None
244
+ self.edge_device_id: Optional[str] = None
245
+ self.public_key: Optional[str] = None
246
+ self.algorithm: Optional[str] = None
247
+ self.fingerprint: Optional[str] = None
248
+ self.status: EdgeDeviceKeyStatus = EdgeDeviceKeyStatus.ACTIVE
249
+ self.created_at: Optional[datetime] = None
250
+ self.retired_at: Optional[datetime] = None
251
+
252
+ def to_dict(self) -> dict:
253
+ obj = {}
254
+ if self.id is not None:
255
+ obj["id"] = str(self.id)
256
+ if self.edge_device_id is not None:
257
+ obj["edgeDeviceId"] = self.edge_device_id
258
+ if self.public_key is not None:
259
+ obj["publicKey"] = self.public_key
260
+ if self.algorithm is not None:
261
+ obj["algorithm"] = self.algorithm
262
+ if self.fingerprint is not None:
263
+ obj["fingerprint"] = self.fingerprint
264
+ obj["status"] = (self.status.value
265
+ if isinstance(self.status, EdgeDeviceKeyStatus)
266
+ else self.status)
267
+ if self.created_at is not None:
268
+ obj["createdAt"] = _ts_out(self.created_at)
269
+ if self.retired_at is not None:
270
+ obj["retiredAt"] = _ts_out(self.retired_at)
271
+ return obj
272
+
273
+ @classmethod
274
+ def from_dict(cls, data: dict) -> "EdgeDeviceKey":
275
+ obj = cls()
276
+ obj.id = data.get("id")
277
+ obj.edge_device_id = data.get("edgeDeviceId") or data.get("edge_device_id")
278
+ obj.public_key = data.get("publicKey") or data.get("public_key")
279
+ obj.algorithm = data.get("algorithm")
280
+ obj.fingerprint = data.get("fingerprint")
281
+ raw_status = data.get("status")
282
+ if raw_status is not None:
283
+ obj.status = EdgeDeviceKeyStatus(str(raw_status).lower())
284
+ obj.created_at = _ts_in(data.get("createdAt") or data.get("created_at"))
285
+ obj.retired_at = _ts_in(data.get("retiredAt") or data.get("retired_at"))
286
+ return obj
287
+
288
+ def api_id(self) -> str:
289
+ return str(self.id) if self.id else None
290
+
291
+ def api_key(self) -> str:
292
+ return self.api_id()
293
+
294
+ def set_id(self, value):
295
+ self.id = str(value)
296
+
297
+ def __repr__(self) -> str:
298
+ return (f"EdgeDeviceKey(edge={self.edge_device_id!r}, "
299
+ f"fingerprint={self.fingerprint!r}, status={self.status!r})")
@@ -0,0 +1,325 @@
1
+ """
2
+ Connection type registry (DEV-6013).
3
+
4
+ One definition per connector type, used for three things:
5
+ - the front-end lists the types and builds each form from its JSON Schemas;
6
+ - the platform validates every connection and mapping against them on write — the
7
+ single place a secret-bearing key is refused outside ``secrets``;
8
+ - the platform renders the edge configuration from the entities.
9
+
10
+ Schemas are plain JSON Schema (draft 2020-12) dicts, so serving them needs nothing.
11
+ Secret fields are ``writeOnly``: accepted on write, never returned.
12
+
13
+ Key names are the entity's, not the legacy ``consumers.json`` spelling, which drifted
14
+ per type (``opc_session_timeout_ms`` vs ``opc_session_timeout``, snap7 ``port`` vs
15
+ ``tcp_port``, RabbitMQ ``RQ_USER`` vs ``user``). Rendering maps them back.
16
+ """
17
+
18
+ from typing import Optional
19
+
20
+ from .connection import ConnectionDirection, TransformKind
21
+
22
+
23
+ _READ = ConnectionDirection.READ.value
24
+ _WRITE = ConnectionDirection.WRITE.value
25
+
26
+
27
+ def _obj(properties: dict, required: list = None) -> dict:
28
+ schema = {"type": "object", "properties": properties, "additionalProperties": False}
29
+ if required:
30
+ schema["required"] = required
31
+ return schema
32
+
33
+
34
+ def _secret(title: str, pem: bool = False) -> dict:
35
+ field = {"type": "string", "title": title, "writeOnly": True}
36
+ if pem:
37
+ field["contentMediaType"] = "application/x-pem-file"
38
+ return field
39
+
40
+
41
+ def _pem(title: str) -> dict:
42
+ return {"type": "string", "title": title, "contentMediaType": "application/x-pem-file"}
43
+
44
+
45
+ _EMPTY = _obj({})
46
+
47
+
48
+ class ConnectionType:
49
+ """
50
+ One supported connector type.
51
+
52
+ :param name: identifier stored on ``Connection.connection_type``.
53
+ :param directions: what mappings may do — read, write, or both.
54
+ :param transforms: transform kinds a read mapping may carry.
55
+ :param polled: mappings take a ``poll_interval_ms`` (else the source pushes).
56
+ :param hardware_id_required: per direction, whether a mapping must name its
57
+ datapoint (False where the source names it).
58
+ :param config: schema for ``Connection.config``.
59
+ :param secrets: schema for ``Connection.secrets`` (plaintext write form).
60
+ :param mapping: per direction, ``{"address": schema, "options": schema}``.
61
+ """
62
+
63
+ def __init__(self, name: str, label: str, description: str,
64
+ directions: list, transforms: list, polled: bool,
65
+ hardware_id_required: dict,
66
+ config: dict, secrets: dict, mapping: dict,
67
+ supports_browse: bool = False):
68
+ self.name = name
69
+ self.label = label
70
+ self.description = description
71
+ self.directions = directions
72
+ self.transforms = transforms
73
+ self.polled = polled
74
+ self.hardware_id_required = hardware_id_required
75
+ self.config = config
76
+ self.secrets = secrets
77
+ self.mapping = mapping
78
+ self.supports_browse = supports_browse
79
+
80
+ @property
81
+ def secret_fields(self) -> list:
82
+ return list(self.secrets.get("properties", {}).keys())
83
+
84
+ def describe(self) -> dict:
85
+ """Summary for the type list: enough to offer the type, not to build a form."""
86
+ return {
87
+ "type": self.name,
88
+ "label": self.label,
89
+ "description": self.description,
90
+ "directions": list(self.directions),
91
+ "transforms": list(self.transforms),
92
+ "polled": self.polled,
93
+ "supportsBrowse": self.supports_browse,
94
+ "secretFields": self.secret_fields,
95
+ }
96
+
97
+ def schema(self) -> dict:
98
+ """Everything a form needs: the summary plus every JSON Schema."""
99
+ out = self.describe()
100
+ out["hardwareIdRequired"] = dict(self.hardware_id_required)
101
+ out["config"] = self.config
102
+ out["secrets"] = self.secrets
103
+ out["mapping"] = {d: dict(s) for d, s in self.mapping.items()}
104
+ return out
105
+
106
+
107
+ # ── OPC-UA ─────────────────────────────────────────────────────────────────────
108
+
109
+ _OPC_SECURITY_POLICIES = ["None", "Basic128Rsa15", "Basic256", "Basic256Sha256",
110
+ "Aes128Sha256RsaOaep", "Aes256Sha256RsaPss"]
111
+
112
+ OPC_UA = ConnectionType(
113
+ name="opc-ua",
114
+ label="OPC-UA",
115
+ description="OPC-UA server — read by polling or subscription, write to nodes.",
116
+ directions=[_READ, _WRITE],
117
+ transforms=[],
118
+ polled=True,
119
+ supports_browse=True,
120
+ hardware_id_required={_READ: False, _WRITE: True},
121
+ config=_obj({
122
+ "server_url": {"type": "string", "title": "Server URL", "pattern": "^opc\\.tcp://"},
123
+ "username": {"type": "string", "title": "Username"},
124
+ "application_uri": {"type": "string", "title": "Application URI"},
125
+ "security_policy": {"type": "string", "title": "Security policy",
126
+ "enum": _OPC_SECURITY_POLICIES, "default": "None"},
127
+ "security_mode": {"type": "string", "title": "Security mode",
128
+ "enum": ["None", "Sign", "SignAndEncrypt"], "default": "None"},
129
+ "client_certificate": _pem("Client certificate"),
130
+ "server_certificate": _pem("Server certificate"),
131
+ "session_timeout_ms": {"type": "integer", "title": "Session timeout (ms)",
132
+ "minimum": 1000, "default": 60000},
133
+ "reconnect_interval_ms": {"type": "integer", "title": "Reconnect interval (ms)",
134
+ "minimum": 1000, "default": 5000},
135
+ "mode": {"type": "string", "title": "Read mode",
136
+ "enum": ["polling", "subscription"], "default": "polling"},
137
+ "name_source": {"type": "string", "title": "Name unmapped nodes by",
138
+ "enum": ["name", "identifier"], "default": "name"},
139
+ "prefix": {"type": "string", "title": "Prefix for unmapped nodes"},
140
+ }, required=["server_url"]),
141
+ secrets=_obj({
142
+ "password": _secret("Password"),
143
+ "client_private_key": _secret("Client private key", pem=True),
144
+ }),
145
+ mapping={
146
+ _READ: {
147
+ "address": _obj({
148
+ "node_id": {"type": "string", "title": "Node id", "examples": ["ns=2;s=Temperature"]},
149
+ "children": {"type": "boolean", "title": "Browse children", "default": False},
150
+ }, required=["node_id"]),
151
+ "options": _obj({
152
+ "batch_size": {"type": "integer", "minimum": 1},
153
+ "max_concurrent_batches": {"type": "integer", "minimum": 1},
154
+ "publishing_interval": {"type": "integer", "minimum": 0, "default": 1000},
155
+ "sampling_interval": {"type": "integer", "minimum": 0, "default": 0},
156
+ "queue_size": {"type": "integer", "minimum": 1, "default": 1},
157
+ }),
158
+ },
159
+ _WRITE: {
160
+ "address": _obj({
161
+ "node_id": {"type": "string", "title": "Node id"},
162
+ }, required=["node_id"]),
163
+ "options": _EMPTY,
164
+ },
165
+ },
166
+ )
167
+
168
+ # ── MQTT ───────────────────────────────────────────────────────────────────────
169
+
170
+ MQTT = ConnectionType(
171
+ name="mqtt",
172
+ label="MQTT",
173
+ description="MQTT broker — subscribe to topics, optional function to parse payloads.",
174
+ directions=[_READ],
175
+ transforms=[TransformKind.FUNCTION.value],
176
+ polled=False,
177
+ hardware_id_required={_READ: False},
178
+ config=_obj({
179
+ "host": {"type": "string", "title": "Broker host"},
180
+ "port": {"type": "integer", "title": "Port", "default": 1883},
181
+ "tls": {"type": "boolean", "title": "TLS", "default": False},
182
+ "username": {"type": "string", "title": "Username"},
183
+ "client_id": {"type": "string", "title": "Client id"},
184
+ "keepalive": {"type": "integer", "title": "Keepalive (s)", "minimum": 1, "default": 60},
185
+ "payload_format": {"type": "string", "enum": ["json", "raw"], "default": "json"},
186
+ "reconnect_min": {"type": "number", "minimum": 0, "default": 1.0},
187
+ "reconnect_max": {"type": "number", "minimum": 0, "default": 120.0},
188
+ "connect_timeout": {"type": "number", "minimum": 0, "default": 30.0},
189
+ "watchdog_timeout": {"type": "number", "minimum": 0, "default": 0},
190
+ }, required=["host"]),
191
+ secrets=_obj({
192
+ "password": _secret("Password"),
193
+ }),
194
+ mapping={
195
+ _READ: {
196
+ "address": _obj({
197
+ "topic": {"type": "string", "title": "Topic", "examples": ["plant/line1/#"]},
198
+ }, required=["topic"]),
199
+ "options": _obj({
200
+ "qos": {"type": "integer", "enum": [0, 1, 2], "default": 1},
201
+ }),
202
+ },
203
+ },
204
+ )
205
+
206
+ # ── Modbus TCP ─────────────────────────────────────────────────────────────────
207
+
208
+ MODBUS = ConnectionType(
209
+ name="modbus",
210
+ label="Modbus TCP",
211
+ description="Modbus TCP device — poll holding or input registers.",
212
+ directions=[_READ],
213
+ transforms=[TransformKind.EXPRESSION.value],
214
+ polled=True,
215
+ hardware_id_required={_READ: True},
216
+ config=_obj({
217
+ "ip_address": {"type": "string", "title": "IP address"},
218
+ "port": {"type": "integer", "title": "Port", "default": 502},
219
+ "unit_id": {"type": "integer", "title": "Unit id", "minimum": 0, "default": 1},
220
+ }, required=["ip_address"]),
221
+ secrets=_EMPTY,
222
+ mapping={
223
+ _READ: {
224
+ "address": _obj({
225
+ "address": {"type": "integer", "title": "Register address", "minimum": 0},
226
+ "quantity": {"type": "integer", "title": "Register count", "minimum": 1},
227
+ "register_type": {"type": "string", "enum": ["holding", "input"],
228
+ "default": "holding"},
229
+ }, required=["address", "quantity"]),
230
+ "options": _obj({
231
+ "pack": {"type": "string", "title": "struct pack format", "examples": [">HH"]},
232
+ "unpack": {"type": "string", "title": "struct unpack format", "examples": [">f"]},
233
+ }, required=["pack", "unpack"]),
234
+ },
235
+ },
236
+ )
237
+
238
+ # ── Siemens S7 (snap7) ─────────────────────────────────────────────────────────
239
+
240
+ SNAP7 = ConnectionType(
241
+ name="snap7",
242
+ label="Siemens S7",
243
+ description="Siemens S7 PLC — poll data blocks.",
244
+ directions=[_READ],
245
+ transforms=[],
246
+ polled=True,
247
+ hardware_id_required={_READ: True},
248
+ config=_obj({
249
+ "address": {"type": "string", "title": "PLC address"},
250
+ "rack": {"type": "integer", "minimum": 0, "default": 0},
251
+ "slot": {"type": "integer", "minimum": 0, "default": 1},
252
+ "tcp_port": {"type": "integer", "title": "Port", "default": 102},
253
+ }, required=["address"]),
254
+ secrets=_EMPTY,
255
+ mapping={
256
+ _READ: {
257
+ "address": _obj({
258
+ "db_number": {"type": "integer", "minimum": 0},
259
+ "start_offset": {"type": "integer", "minimum": 0},
260
+ "size": {"type": "integer", "minimum": 1},
261
+ "index": {"type": "integer", "title": "Byte index in the block", "minimum": 0},
262
+ "type": {"type": "string", "enum": ["dword", "word", "bool", "real", "int"]},
263
+ "bit_position": {"type": "integer", "minimum": 0, "maximum": 7},
264
+ }, required=["db_number", "start_offset", "size", "index", "type"]),
265
+ "options": _EMPTY,
266
+ },
267
+ },
268
+ )
269
+
270
+ # ── RabbitMQ (external queue) ──────────────────────────────────────────────────
271
+
272
+ RABBITMQ = ConnectionType(
273
+ name="rabbitmq",
274
+ label="RabbitMQ",
275
+ description="External RabbitMQ broker — consume a queue, function to parse payloads.",
276
+ directions=[_READ],
277
+ transforms=[TransformKind.FUNCTION.value],
278
+ polled=False,
279
+ hardware_id_required={_READ: False},
280
+ config=_obj({
281
+ "host": {"type": "string", "title": "Host"},
282
+ "port": {"type": "integer", "title": "Port", "default": 5671},
283
+ "vhost": {"type": "string", "title": "Virtual host", "default": "/"},
284
+ "tls": {"type": "boolean", "title": "TLS", "default": True},
285
+ "username": {"type": "string", "title": "Username"},
286
+ }, required=["host", "username"]),
287
+ secrets=_obj({
288
+ "password": _secret("Password"),
289
+ }),
290
+ mapping={
291
+ _READ: {
292
+ "address": _obj({
293
+ "queue": {"type": "string", "title": "Queue", "default": "messages"},
294
+ }, required=["queue"]),
295
+ "options": _obj({
296
+ "prefetch": {"type": "integer", "minimum": 1},
297
+ "error_queue": {"type": "string"},
298
+ }),
299
+ },
300
+ },
301
+ )
302
+
303
+
304
+ CONNECTION_TYPES = {t.name: t for t in (OPC_UA, MQTT, MODBUS, SNAP7, RABBITMQ)}
305
+
306
+ # Transform payloads, whatever the type (the type decides which kinds it accepts).
307
+ TRANSFORM_SCHEMAS = {
308
+ TransformKind.EXPRESSION.value: _obj({
309
+ "kind": {"const": TransformKind.EXPRESSION.value},
310
+ "expression": {"type": "string", "title": "Expression on x", "examples": ["x / 100"]},
311
+ }, required=["kind", "expression"]),
312
+ TransformKind.FUNCTION.value: _obj({
313
+ "kind": {"const": TransformKind.FUNCTION.value},
314
+ "filepath": {"type": "string", "title": "Function file"},
315
+ "function_name": {"type": "string", "title": "Function name"},
316
+ }, required=["kind", "filepath", "function_name"]),
317
+ }
318
+
319
+
320
+ def get_connection_type(name: str) -> Optional[ConnectionType]:
321
+ return CONNECTION_TYPES.get((name or "").lower())
322
+
323
+
324
+ def list_connection_types() -> list:
325
+ return [t.describe() for t in CONNECTION_TYPES.values()]
@@ -0,0 +1,210 @@
1
+ """
2
+ Production Planner product definitions (DEV-6070, epic DEV-6044).
3
+
4
+ ProductKind — what role a product plays: raw material, container, finished product,
5
+ or a kind the customer adds. The three built-in kinds (``BUILTIN_PRODUCT_KINDS``)
6
+ are seeded on every tenant and cannot be deleted.
7
+
8
+ Product — something the plant consumes or makes, identified by a Wizata key
9
+ (e.g. ``can_33_coke``). ``category`` is a backend category id (Volume, Count, ...):
10
+ it says how a quantity of that product is expressed.
11
+
12
+ ProductComponent — one line of a product's bill of materials: ``product`` is made of
13
+ ``quantity`` ``unit`` of ``composed_of``. The unit is a backend unit id and must
14
+ belong to the category of ``composed_of`` (33 ml of coke, 1 pcs of can).
15
+ ``unit_symbol`` is filled on read only.
16
+
17
+ All of it is served under ``/dsapi/planner`` and gated by the Production Planner
18
+ add-on license (DEV-6069).
19
+ """
20
+
21
+ import re
22
+ from datetime import datetime, timezone
23
+ from typing import Optional
24
+
25
+ #: key -> (name, description). Seeded by alembic 0019; editable, never deletable.
26
+ BUILTIN_PRODUCT_KINDS = {
27
+ "raw": ("Raw Material",
28
+ "A material bought or produced upstream and consumed to make other "
29
+ "products (e.g. syrup, water, aluminium)."),
30
+ "container": ("Containers",
31
+ "Packaging that holds or carries a product (e.g. can, bottle, box, pallet)."),
32
+ "finished": ("Finished Product",
33
+ "A product ready to ship or sell, made from raw materials and containers."),
34
+ }
35
+
36
+ #: A Wizata key: starts with a letter or digit, then letters, digits, ``_ - .``.
37
+ PRODUCT_KEY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$")
38
+
39
+
40
+ def is_valid_product_key(key) -> bool:
41
+ return isinstance(key, str) and PRODUCT_KEY_PATTERN.match(key) is not None
42
+
43
+
44
+ def _ts_out(value):
45
+ if value is None:
46
+ return None
47
+ if value.tzinfo is None:
48
+ value = value.replace(tzinfo=timezone.utc)
49
+ return value.isoformat()
50
+
51
+
52
+ def _ts_in(value):
53
+ if value is None or isinstance(value, datetime):
54
+ return value
55
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
56
+
57
+
58
+ def _uuid_str(value):
59
+ """Backend ids travel as strings, lower-cased so equal ids compare equal."""
60
+ if value is None or value == "":
61
+ return None
62
+ return str(value).lower()
63
+
64
+
65
+ class ProductKind:
66
+ """ProductKind entity stored in PostgreSQL ``"ProductKind"``. PK ``key``."""
67
+
68
+ @classmethod
69
+ def route(cls):
70
+ return "planner/productkinds"
71
+
72
+ def __init__(self, key: str = None, name: str = None, description: str = None):
73
+ self.key: Optional[str] = key
74
+ self.name: Optional[str] = name
75
+ self.description: Optional[str] = description
76
+ self.created_at: Optional[datetime] = None
77
+ self.updated_at: Optional[datetime] = None
78
+
79
+ @property
80
+ def builtin(self) -> bool:
81
+ return self.key in BUILTIN_PRODUCT_KINDS
82
+
83
+ def to_dict(self) -> dict:
84
+ obj = {}
85
+ if self.key is not None:
86
+ obj["key"] = self.key
87
+ if self.name is not None:
88
+ obj["name"] = self.name
89
+ if self.description is not None:
90
+ obj["description"] = self.description
91
+ obj["builtin"] = self.builtin
92
+ if self.created_at is not None:
93
+ obj["createdAt"] = _ts_out(self.created_at)
94
+ if self.updated_at is not None:
95
+ obj["updatedAt"] = _ts_out(self.updated_at)
96
+ return obj
97
+
98
+ @classmethod
99
+ def from_dict(cls, data: dict) -> "ProductKind":
100
+ obj = cls()
101
+ obj.key = data.get("key")
102
+ obj.name = data.get("name")
103
+ obj.description = data.get("description")
104
+ obj.created_at = _ts_in(data.get("createdAt") or data.get("created_at"))
105
+ obj.updated_at = _ts_in(data.get("updatedAt") or data.get("updated_at"))
106
+ return obj
107
+
108
+ def api_id(self) -> str:
109
+ return self.key
110
+
111
+ def __repr__(self) -> str:
112
+ return f"ProductKind(key={self.key!r}, name={self.name!r})"
113
+
114
+
115
+ class Product:
116
+ """Product entity stored in PostgreSQL ``"Product"``. PK ``key``."""
117
+
118
+ @classmethod
119
+ def route(cls):
120
+ return "planner/products"
121
+
122
+ def __init__(self, key: str = None, name: str = None, category=None,
123
+ kind: str = None, description: str = None):
124
+ self.key: Optional[str] = key
125
+ self.name: Optional[str] = name
126
+ self.category: Optional[str] = _uuid_str(category)
127
+ self.kind: Optional[str] = kind
128
+ self.description: Optional[str] = description
129
+ self.created_at: Optional[datetime] = None
130
+ self.updated_at: Optional[datetime] = None
131
+
132
+ def to_dict(self) -> dict:
133
+ obj = {}
134
+ if self.key is not None:
135
+ obj["key"] = self.key
136
+ if self.name is not None:
137
+ obj["name"] = self.name
138
+ if self.category is not None:
139
+ obj["category"] = self.category
140
+ if self.kind is not None:
141
+ obj["kind"] = self.kind
142
+ if self.description is not None:
143
+ obj["description"] = self.description
144
+ if self.created_at is not None:
145
+ obj["createdAt"] = _ts_out(self.created_at)
146
+ if self.updated_at is not None:
147
+ obj["updatedAt"] = _ts_out(self.updated_at)
148
+ return obj
149
+
150
+ @classmethod
151
+ def from_dict(cls, data: dict) -> "Product":
152
+ obj = cls()
153
+ obj.key = data.get("key")
154
+ obj.name = data.get("name")
155
+ obj.category = _uuid_str(data.get("category"))
156
+ obj.kind = data.get("kind")
157
+ obj.description = data.get("description")
158
+ obj.created_at = _ts_in(data.get("createdAt") or data.get("created_at"))
159
+ obj.updated_at = _ts_in(data.get("updatedAt") or data.get("updated_at"))
160
+ return obj
161
+
162
+ def api_id(self) -> str:
163
+ return self.key
164
+
165
+ def __repr__(self) -> str:
166
+ return f"Product(key={self.key!r}, kind={self.kind!r}, category={self.category!r})"
167
+
168
+
169
+ class ProductComponent:
170
+ """
171
+ One bill-of-materials line, stored in PostgreSQL ``"ProductComponent"``.
172
+ PK (``product``, ``composed_of``).
173
+ """
174
+
175
+ def __init__(self, product: str = None, composed_of: str = None,
176
+ quantity: float = None, unit=None):
177
+ self.product: Optional[str] = product
178
+ self.composed_of: Optional[str] = composed_of
179
+ self.quantity: Optional[float] = quantity
180
+ self.unit: Optional[str] = _uuid_str(unit)
181
+ #: read-only, filled from the backend unit on read
182
+ self.unit_symbol: Optional[str] = None
183
+
184
+ def to_dict(self) -> dict:
185
+ obj = {}
186
+ if self.product is not None:
187
+ obj["product"] = self.product
188
+ if self.composed_of is not None:
189
+ obj["composedOf"] = self.composed_of
190
+ if self.quantity is not None:
191
+ obj["quantity"] = self.quantity
192
+ if self.unit is not None:
193
+ obj["unit"] = self.unit
194
+ if self.unit_symbol is not None:
195
+ obj["unitSymbol"] = self.unit_symbol
196
+ return obj
197
+
198
+ @classmethod
199
+ def from_dict(cls, data: dict) -> "ProductComponent":
200
+ obj = cls()
201
+ obj.product = data.get("product")
202
+ obj.composed_of = data.get("composedOf") or data.get("composed_of")
203
+ obj.quantity = data.get("quantity")
204
+ obj.unit = _uuid_str(data.get("unit"))
205
+ obj.unit_symbol = data.get("unitSymbol") or data.get("unit_symbol")
206
+ return obj
207
+
208
+ def __repr__(self) -> str:
209
+ return (f"ProductComponent({self.product!r} <- {self.quantity!r} {self.unit_symbol or self.unit!r} "
210
+ f"of {self.composed_of!r})")
@@ -0,0 +1 @@
1
+ __version__ = "2.2.0.dev3"
@@ -54,6 +54,7 @@ from .mobile_asset import MobileAssetTile, MobileAssetConfig
54
54
  from .version import __version__
55
55
  import ast
56
56
  from .deployment import Deployment, DeploymentStatus
57
+ from .product import ProductKind, Product, ProductComponent
57
58
  from .api_config import _registry
58
59
 
59
60
  from .streamlit_utils import get_streamlit_token, get_streamlit_domain
@@ -2731,6 +2732,142 @@ class WizataDSAPIClient(ApiInterface, ApiDtoInterface):
2731
2732
  raise self.__raise_error(response)
2732
2733
 
2733
2734
 
2735
+ # ------------------------------------------------------------------
2736
+ # Production Planner - product definitions (DEV-6070)
2737
+ #
2738
+ # Everything below answers 403 PLANNER_LICENSE_DISABLED while the
2739
+ # Production Planner add-on is not active on the environment (DEV-6069).
2740
+ # ------------------------------------------------------------------
2741
+
2742
+ def _planner_request(self, method: str, route: str, body=None, params: dict = None,
2743
+ expected=(200,)):
2744
+ response = requests.request(
2745
+ method,
2746
+ self.__url() + route,
2747
+ headers=self.__header(),
2748
+ data=json.dumps(body) if body is not None else None,
2749
+ params=params,
2750
+ )
2751
+ if response.status_code not in expected:
2752
+ raise self.__raise_error(response)
2753
+ return response
2754
+
2755
+ @staticmethod
2756
+ def _planner_key(value) -> str:
2757
+ key = value.key if hasattr(value, "key") else value
2758
+ if not key:
2759
+ raise ValueError("please provide a key")
2760
+ return urllib.parse.quote(str(key), safe="")
2761
+
2762
+ def search_product_kinds(self, search_query=None) -> PagedQueryResult:
2763
+ """
2764
+ The only way to list product kinds: the built-in raw / container / finished and
2765
+ the tenant's own.
2766
+
2767
+ :param search_query: SearchQuery or dict; filters on ``name``, ``_search`` over
2768
+ key and name.
2769
+ :return: PagedQueryResult with ProductKind results.
2770
+ """
2771
+ if search_query is None:
2772
+ search_query = SearchQuery()
2773
+ elif isinstance(search_query, dict):
2774
+ search_query = SearchQuery.from_dict(search_query)
2775
+ response = self._planner_request("POST", "planner/productkinds/search",
2776
+ body=search_query.to_dict())
2777
+ return PagedQueryResult.from_dict(response.json(), dto_class=ProductKind)
2778
+
2779
+ def get_product_kind(self, key: str):
2780
+ """
2781
+ :param key: product kind key (e.g. ``raw``).
2782
+ :return: ProductKind, or None when it does not exist.
2783
+ """
2784
+ response = self._planner_request("GET", f"planner/productkinds/{self._planner_key(key)}/",
2785
+ expected=(200, 404))
2786
+ return ProductKind.from_dict(response.json()) if response.status_code == 200 else None
2787
+
2788
+ def create_product_kind(self, kind: ProductKind) -> ProductKind:
2789
+ response = self._planner_request("POST", "planner/productkinds/", body=kind.to_dict(),
2790
+ expected=(201,))
2791
+ return ProductKind.from_dict(response.json())
2792
+
2793
+ def update_product_kind(self, kind: ProductKind) -> ProductKind:
2794
+ response = self._planner_request("PUT", f"planner/productkinds/{self._planner_key(kind)}/",
2795
+ body=kind.to_dict())
2796
+ return ProductKind.from_dict(response.json())
2797
+
2798
+ def delete_product_kind(self, kind):
2799
+ """
2800
+ :param kind: ProductKind or its key. A built-in kind, or a kind still used by a
2801
+ product, is refused (409).
2802
+ """
2803
+ self._planner_request("DELETE", f"planner/productkinds/{self._planner_key(kind)}/",
2804
+ expected=(204,))
2805
+
2806
+ def search_products(self, search_query=None) -> PagedQueryResult:
2807
+ """
2808
+ The only way to list products.
2809
+
2810
+ :param search_query: SearchQuery or dict; filters on ``kind``, ``category``
2811
+ (backend category id), ``name``, and ``_search`` over key and name.
2812
+ :return: PagedQueryResult with Product results.
2813
+ """
2814
+ if search_query is None:
2815
+ search_query = SearchQuery()
2816
+ elif isinstance(search_query, dict):
2817
+ search_query = SearchQuery.from_dict(search_query)
2818
+ response = self._planner_request("POST", "planner/products/search",
2819
+ body=search_query.to_dict())
2820
+ return PagedQueryResult.from_dict(response.json(), dto_class=Product)
2821
+
2822
+ def get_product(self, key: str):
2823
+ """
2824
+ :return: Product, or None when it does not exist.
2825
+ """
2826
+ response = self._planner_request("GET", f"planner/products/{self._planner_key(key)}/",
2827
+ expected=(200, 404))
2828
+ return Product.from_dict(response.json()) if response.status_code == 200 else None
2829
+
2830
+ def create_product(self, product: Product) -> Product:
2831
+ response = self._planner_request("POST", "planner/products/", body=product.to_dict(),
2832
+ expected=(201,))
2833
+ return Product.from_dict(response.json())
2834
+
2835
+ def update_product(self, product: Product) -> Product:
2836
+ response = self._planner_request("PUT", f"planner/products/{self._planner_key(product)}/",
2837
+ body=product.to_dict())
2838
+ return Product.from_dict(response.json())
2839
+
2840
+ def delete_product(self, product):
2841
+ """
2842
+ :param product: Product or its key. Refused (409) while another product is made
2843
+ of it; its own bill of materials is deleted with it.
2844
+ """
2845
+ self._planner_request("DELETE", f"planner/products/{self._planner_key(product)}/",
2846
+ expected=(204,))
2847
+
2848
+ def get_product_components(self, product) -> list:
2849
+ """
2850
+ :param product: Product or its key.
2851
+ :return: its bill of materials, a list of ProductComponent with unit_symbol set.
2852
+ """
2853
+ response = self._planner_request(
2854
+ "GET", f"planner/products/{self._planner_key(product)}/components/")
2855
+ return [ProductComponent.from_dict(c) for c in response.json()]
2856
+
2857
+ def set_product_components(self, product, components: list) -> list:
2858
+ """
2859
+ Replace a product's bill of materials (an empty list clears it).
2860
+
2861
+ :param product: Product or its key.
2862
+ :param components: list of ProductComponent (``product`` may be left unset).
2863
+ :return: the stored components, unit_symbol set.
2864
+ """
2865
+ body = [c.to_dict() if isinstance(c, ProductComponent) else c for c in components]
2866
+ response = self._planner_request(
2867
+ "PUT", f"planner/products/{self._planner_key(product)}/components/", body=body)
2868
+ return [ProductComponent.from_dict(c) for c in response.json()]
2869
+
2870
+
2734
2871
  def api() -> WizataDSAPIClient:
2735
2872
  """
2736
2873
  Create a WizataDSAPIClient from environment variables.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wizata_dsapi
3
- Version: 2.2.0.dev1
3
+ Version: 2.2.0.dev3
4
4
  Summary: Wizata Data Science Toolkit
5
5
  Author: Wizata S.A.
6
6
  Author-email: info@wizata.com
@@ -7,6 +7,8 @@ wizata_dsapi/api_dto.py
7
7
  wizata_dsapi/api_interface.py
8
8
  wizata_dsapi/bucket.py
9
9
  wizata_dsapi/business_label.py
10
+ wizata_dsapi/connection.py
11
+ wizata_dsapi/connection_types.py
10
12
  wizata_dsapi/context.py
11
13
  wizata_dsapi/dashboard.py
12
14
  wizata_dsapi/dataframe_toolkit.py
@@ -34,6 +36,7 @@ wizata_dsapi/paged_query_result.py
34
36
  wizata_dsapi/pipeline.py
35
37
  wizata_dsapi/pipeline_image.py
36
38
  wizata_dsapi/plot.py
39
+ wizata_dsapi/product.py
37
40
  wizata_dsapi/request.py
38
41
  wizata_dsapi/script.py
39
42
  wizata_dsapi/search.py
@@ -1 +0,0 @@
1
- __version__ = "2.2.0.dev1"