wizata-dsapi 2.2.0.dev2__py3-none-any.whl → 2.2.0.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wizata_dsapi/__init__.py CHANGED
@@ -65,6 +65,7 @@ from .connection import Connection, ConnectionMapping, ConnectionDirection, Tran
65
65
  EdgeDeviceKey, EdgeDeviceKeyStatus
66
66
  from .connection_types import ConnectionType, CONNECTION_TYPES, TRANSFORM_SCHEMAS, get_connection_type, \
67
67
  list_connection_types
68
+ from .product import ProductKind, Product, ProductComponent, BUILTIN_PRODUCT_KINDS, is_valid_product_key
68
69
  from .dashboard import Dashboard, DashboardType
69
70
  from .mobile_asset import MobileAssetTile, MobileAssetTileType, MobileAssetConfig
70
71
  from .graylog_log import GraylogLog
@@ -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})")
wizata_dsapi/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2.2.0.dev2"
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.dev2
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
@@ -1,4 +1,4 @@
1
- wizata_dsapi/__init__.py,sha256=8zazLs_NYKRhveJt3vhy1SxwXHvBv5fEl6yfX-UilrI,3374
1
+ wizata_dsapi/__init__.py,sha256=E5OJj1DhWW9I96DmE1fzMPsgvkPCqeEtnNAK05sEQgQ,3479
2
2
  wizata_dsapi/api_config.py,sha256=zOgvpkB3FE2ny_2vAui7B1hcAMFCLvfb2ep3m-b7dIw,8270
3
3
  wizata_dsapi/api_dto.py,sha256=F9HjNa_vqYhdEMZwwk8E3SmCxuMBcxFnMp1eE9dxLvg,2910
4
4
  wizata_dsapi/api_interface.py,sha256=mCq0-rlwWL4GKB2QnxSPuyTecYOXXknmPOlQe_SEf2I,14670
@@ -33,6 +33,7 @@ wizata_dsapi/paged_query_result.py,sha256=QOpJMAS6rxJfiz3a5oSfc0gOvwQeZTEhOtBoSs
33
33
  wizata_dsapi/pipeline.py,sha256=mMkNzFQsC9s1ZuC0HrfLG1BsZCnYYlrrhPlo6Out1Zs,40872
34
34
  wizata_dsapi/pipeline_image.py,sha256=WtMY0_ncba9WzZ96KQINiYshJegQdArzhspKTGWBgVA,7758
35
35
  wizata_dsapi/plot.py,sha256=KLeYj6xpG_XFSDuR4A8Iqp2fmAVLeDsHaUOVT2sRUYM,4033
36
+ wizata_dsapi/product.py,sha256=DEuqi-1KDih3fyLEmVlsKao57tCpPJQ2sTwhEqe-r0A,7577
36
37
  wizata_dsapi/request.py,sha256=t5eykjLnhREYrcBcZcFyxtk89nBCP44jC0SjEChmHRg,27785
37
38
  wizata_dsapi/script.py,sha256=1Mjw3BNxN_R5dx0f55T0WuebqqRr_69rsK5yuQZOjmQ,19467
38
39
  wizata_dsapi/search.py,sha256=pHX-xGIB0E6gJkTxXmLjFe_b8-0gei7hfkW0JnxzCl0,4691
@@ -44,10 +45,10 @@ wizata_dsapi/trigger.py,sha256=waOX2AhrLvFzNFRQ535j6iemgg_QJP2QsWGL2ipljFg,8848
44
45
  wizata_dsapi/twin.py,sha256=8AnrxSaL7dMrILXJ3-GGmmUz078j3qqwzDTe6i86xqA,12672
45
46
  wizata_dsapi/twinregistration.py,sha256=iXt55AVGAQNl0jmxa3ZVynPqlSsIunFf4sLfp-MWTw0,13244
46
47
  wizata_dsapi/user.py,sha256=-hchKAMZULu9P00RUHDqu_5AT4NI5BYOJd8XCBrt3eQ,1950
47
- wizata_dsapi/version.py,sha256=bKxMxf4FLo3j7fCZ_ayiGhLIAkPquwpEeY8g9TOU0P8,27
48
+ wizata_dsapi/version.py,sha256=8rZEHkrNQw-J8A6mFRKebsH-vIimD3thK_hO18YaEmk,27
48
49
  wizata_dsapi/wizard_function.py,sha256=RbM7W7Gf-6Rhp_1dU9DBYkHaciknGAGvuAndhAS_vyo,942
49
50
  wizata_dsapi/wizard_request.py,sha256=v6BaqKLKvTWmUSo0_gda9FabAQz5x_-GOH1Av50GzFo,3762
50
- wizata_dsapi/wizata_dsapi_client.py,sha256=DX8WXKT5gi_nfHHX2-jz4dvUe-Bo_HszQmr94R3JvV0,112418
51
+ wizata_dsapi/wizata_dsapi_client.py,sha256=bhAaInsbPBVc6EPe56_-U2_ggoEqHBfVH-1FhmobjqI,118779
51
52
  wizata_dsapi/words.py,sha256=tV8CqzCqODZCV7PgBxBF5exBxeF_ya9t5DiUy-cg6Sg,1535
52
53
  wizata_dsapi/models/__init__.py,sha256=O3Tusyhl8hFb-5QaEBY7DJISRB8Gf_Xbxxr-aFDR1_U,380
53
54
  wizata_dsapi/models/common.py,sha256=5BC3Dc6kMvqPGxHgoCuGVOynu_F1MWh7EJSzXIICSSg,27196
@@ -56,8 +57,8 @@ wizata_dsapi/plots/common.py,sha256=7F4WMBaddubnCpdSafgaI9U9Sz0Sngg0VREP_vnIMno,
56
57
  wizata_dsapi/plots/theme.py,sha256=D4sSgEwabXVxF3z1GV8DivXJbbY61KgxXAWfjUbNDAc,3594
57
58
  wizata_dsapi/scripts/__init__.py,sha256=9fRdTMuuXcqMkIsUPf5dwFsTWwBzKteQdAMVkyy2acQ,212
58
59
  wizata_dsapi/scripts/common.py,sha256=T2cP8aGPg8OgIvBWuQBNonVtph_dHlNFubByuYrLb0o,15675
59
- wizata_dsapi-2.2.0.dev2.dist-info/licenses/LICENSE.txt,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
60
- wizata_dsapi-2.2.0.dev2.dist-info/METADATA,sha256=dVvL2OBGr42dKhKzMnfULhMExHhmUkJkAW1X8oR7_uU,14037
61
- wizata_dsapi-2.2.0.dev2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
62
- wizata_dsapi-2.2.0.dev2.dist-info/top_level.txt,sha256=-OeTJbEnh5DuWyTOHtvw0Dw3LRg3G27TNS6W4ZtfwPs,13
63
- wizata_dsapi-2.2.0.dev2.dist-info/RECORD,,
60
+ wizata_dsapi-2.2.0.dev3.dist-info/licenses/LICENSE.txt,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
61
+ wizata_dsapi-2.2.0.dev3.dist-info/METADATA,sha256=ZxA42rrbx5Sv6s_IHW7bl-wnqUG7h-EeVdFyIoPMFy0,14037
62
+ wizata_dsapi-2.2.0.dev3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
63
+ wizata_dsapi-2.2.0.dev3.dist-info/top_level.txt,sha256=-OeTJbEnh5DuWyTOHtvw0Dw3LRg3G27TNS6W4ZtfwPs,13
64
+ wizata_dsapi-2.2.0.dev3.dist-info/RECORD,,