diff --git a/docs/api_reference.rst b/docs/api_reference.rst index b9181186..e3bd991a 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -9,6 +9,7 @@ API Reference api_reference/core api_reference/tag + api_reference/product api_reference/testmonitor api_reference/dataframe api_reference/spec diff --git a/docs/api_reference/product.rst b/docs/api_reference/product.rst new file mode 100644 index 00000000..12557748 --- /dev/null +++ b/docs/api_reference/product.rst @@ -0,0 +1,23 @@ +.. _api_tag_page: + +nisystemlink.clients.product +====================== + +.. autoclass:: nisystemlink.clients.product.ProductClient + :exclude-members: __init__ + + .. automethod:: __init__ + .. automethod:: create_products + .. automethod:: get_products + .. automethod:: query_products + .. automethod:: query_product_values + .. automethod:: update_products + .. automethod:: delete_product + .. automethod:: delete_products + +.. automodule:: nisystemlink.clients.product.models + :members: + :imported-members: + +.. automodule:: nisystemlink.clients.product.utilities + :members: \ No newline at end of file diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 0efe9358..ffdb564a 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -76,6 +76,34 @@ Subscribe to tag changes :language: python :linenos: + +Product API +------- + +Overview +~~~~~~~~ + +The :class:`.ProductClient` class is the primary entry point of the Product API. + +When constructing a :class:`.ProductClient`, you can pass an +:class:`.HttpConfiguration` (like one retrieved from the +:class:`.HttpConfigurationManager`), or let :class:`.ProductClient` use the +default connection. The default connection depends on your environment. + +With a :class:`.ProductClient` object, you can: + +* Create, update, query, and delete Products + +Examples +~~~~~~~~ + +Create, query, update, and delete some products + +.. literalinclude:: ../examples/product/products.py + :language: python + :linenos: + + DataFrame API ------- diff --git a/examples/product/products.py b/examples/product/products.py new file mode 100644 index 00000000..756cc54c --- /dev/null +++ b/examples/product/products.py @@ -0,0 +1,83 @@ +from nisystemlink.clients.core import HttpConfiguration +from nisystemlink.clients.product import ProductClient +from nisystemlink.clients.product.models import ( + Product, + ProductField, + QueryProductsRequest, + QueryProductValuesRequest, +) + +name = "Example Name" +family = "Example Family" + + +def create_some_products(): + """Create two example products on your server.""" + new_products = [ + Product( + part_number="Example 123 AA", + name=name, + family=family, + keywords=["original keyword"], + properties={"original property key": "yes"}, + ), + Product( + part_number="Example 123 AA1", + name=name, + family=family, + keywords=["original keyword"], + properties={"original property key": "original"}, + ), + ] + create_response = client.create_products(new_products) + return create_response + + +# Setup the server configuration to point to your instance of SystemLink Enterprise +server_configuration = HttpConfiguration( + server_uri="https://yourserver.yourcompany.com", + api_key="YourAPIKeyGeneratedFromSystemLink", +) +client = ProductClient(configuration=server_configuration) + +# Get all the products using the continuation token in batches of 100 at a time. +response = client.get_products_paged(take=100, return_count=True) +all_products = response.products +while response.continuation_token: + response = client.get_products_paged( + take=100, continuation_token=response.continuation_token, return_count=True + ) + all_products.extend(response.products) + +create_response = create_some_products() + +# use get for first product created +created_product = client.get_product(create_response.products[0].id) + +# Query products without continuation +query_request = QueryProductsRequest( + filter=f'family="{family}" && name="{name}"', + return_count=True, + order_by=ProductField.FAMILY, +) +response = client.query_products_paged(query_request) + +# Update the first product that you just created and replace the keywords +updated_product = create_response.products[0] +updated_product.keywords = ["new keyword"] +updated_product.properties = {"new property key": "new value"} +update_response = client.update_products([create_response.products[0]], replace=True) + +# Query for just the ids of products that match the family +values_query = QueryProductValuesRequest( + filter=f'family="{family}"', field=ProductField.ID +) +values_response = client.query_product_values(query=values_query) + +# delete each created product individually by id +for product in create_response.products: + client.delete_product(product.id) + +# Create some more and delete them with a single call to delete. +create_response = create_some_products() +client.delete_products([product.id for product in create_response.products]) diff --git a/nisystemlink/clients/product/__init__.py b/nisystemlink/clients/product/__init__.py new file mode 100644 index 00000000..7b357f5d --- /dev/null +++ b/nisystemlink/clients/product/__init__.py @@ -0,0 +1,3 @@ +from ._product_client import ProductClient + +# flake8: noqa diff --git a/nisystemlink/clients/product/_product_client.py b/nisystemlink/clients/product/_product_client.py new file mode 100644 index 00000000..de627c5e --- /dev/null +++ b/nisystemlink/clients/product/_product_client.py @@ -0,0 +1,182 @@ +"""Implementation of Product Client""" + +from typing import List, Optional + +from nisystemlink.clients import core +from nisystemlink.clients.core._uplink._base_client import BaseClient +from nisystemlink.clients.core._uplink._methods import delete, get, post +from nisystemlink.clients.product.models import Product +from uplink import Field, Query, returns + +from . import models + + +class ProductClient(BaseClient): + def __init__(self, configuration: Optional[core.HttpConfiguration] = None): + """Initialize an instance. + + Args: + configuration: Defines the web server to connect to and information about + how to connect. If not provided, the + :class:`HttpConfigurationManager ` + is used to obtain the configuration. + + Raises: + ApiException: if unable to communicate with the Product Service. + """ + if configuration is None: + configuration = core.HttpConfigurationManager.get_configuration() + super().__init__(configuration, base_path="/nitestmonitor/v2/") + + @post("products", args=[Field("products")]) + def create_products( + self, products: List[Product] + ) -> models.CreateProductsPartialSuccess: + """Creates one or more products and returns errors for failed creations. + + Args: + products: A list of products to attempt to create. + + Returns: A list of created products, products that failed to create, and errors for + failures. + + Raises: + ApiException: if unable to communicate with the ``/nitestmonitor`` service of provided invalid + arguments. + """ + ... + + @get( + "products", + args=[Query("continuationToken"), Query("take"), Query("returnCount")], + ) + def get_products_paged( + self, + continuation_token: Optional[str] = None, + take: Optional[int] = None, + return_count: Optional[bool] = None, + ) -> models.PagedProducts: + """Reads a list of products. + + Args: + continuation_token: The token used to paginate results. + take: The number of products to get in this request. + return_count: Whether or not to return the total number of products available. + + Returns: + A list of products. + + Raises: + ApiException: if unable to communicate with the ``/nitestmonitor`` Service + or provided an invalid argument. + """ + ... + + @get("products/{id}") + def get_product(self, id: str) -> models.Product: + """Retrieves a single product by id. + + Args: + id (str): Unique ID of a products. + + Returns: + The single product matching `id` + + Raises: + ApiException: if unable to communicate with the ``/nitestmonitor`` Service + or provided an invalid argument. + """ + ... + + @post("query-products") + def query_products_paged( + self, query: models.QueryProductsRequest + ) -> models.PagedProducts: + """Queries for products that match the filter. + + Args: + query : The query contains a DynamicLINQ query string in addition to other details + about how to filter and return the list of products. + + Returns: + A paged list of products with a continuation token to get the next page. + + Raises: + ApiException: if unable to communicate with the ``/nitestmonitor`` Service or provided invalid + arguments. + """ + ... + + @returns.json # type: ignore + @post("query-product-values") + def query_product_values( + self, query: models.QueryProductValuesRequest + ) -> List[str]: + """Queries for products that match the query and returns a list of the requested field. + + Args: + query : The query for the fields you want. + + Returns: + A list of the values of the field you requested. + + Raises: + ApiException: if unable to communicate with the ``/nitestmonitor`` Service or provided + invalid arguments. + """ + ... + + @post("update-products", args=[Field("products"), Field("replace")]) + def update_products( + self, products: List[Product], replace: bool = False + ) -> models.CreateProductsPartialSuccess: + """Updates a list of products with optional field replacement. + + Args: + `products`: A list of products to update. Products are matched for update by id. + `replace`: Replace the existing fields instead of merging them. Defaults to `False`. + If this is `True`, then `keywords` and `properties` for the product will be + replaced by what is in the `products` provided in this request. + If this is `False`, then the `keywords` and `properties` in this request will + merge with what is already present in the server resource. + + Returns: A list of updates products, products that failed to update, and errors for + failures. + + Raises: + ApiException: if unable to communicate with the ``/nitestmonitor`` Service + or provided an invalid argument. + """ + ... + + @delete("products/{id}") + def delete_product(self, id: str) -> None: + """Deletes a single product by id. + + Args: + id (str): Unique ID of a product. + + Raises: + ApiException: if unable to communicate with the ``/nitestmonitor`` Service + or provided an invalid argument. + """ + ... + + @post("delete-products", args=[Field("ids")]) + def delete_products( + self, ids: List[str] + ) -> Optional[models.DeleteProductsPartialSuccess]: + """Deletes multiple products. + + Args: + ids (List[str]): List of unique IDs of products. + + Returns: + A partial success if any products failed to delete, or None if all + products were deleted successfully. + + Raises: + ApiException: if unable to communicate with the ``/nitestmonitor`` Service + or provided an invalid argument. + """ + ... diff --git a/nisystemlink/clients/product/models/__init__.py b/nisystemlink/clients/product/models/__init__.py new file mode 100644 index 00000000..88f6f68d --- /dev/null +++ b/nisystemlink/clients/product/models/__init__.py @@ -0,0 +1,11 @@ +from ._product import Product +from ._create_products_partial_success import CreateProductsPartialSuccess +from ._delete_products_partial_success import DeleteProductsPartialSuccess +from ._paged_products import PagedProducts +from ._query_products_request import ( + QueryProductsRequest, + ProductField, + QueryProductValuesRequest, +) + +# flake8: noqa diff --git a/nisystemlink/clients/product/models/_create_products_partial_success.py b/nisystemlink/clients/product/models/_create_products_partial_success.py new file mode 100644 index 00000000..f8eee6d7 --- /dev/null +++ b/nisystemlink/clients/product/models/_create_products_partial_success.py @@ -0,0 +1,22 @@ +from typing import List, Optional + +from nisystemlink.clients.core import ApiError +from nisystemlink.clients.core._uplink._json_model import JsonModel +from nisystemlink.clients.product.models import Product + + +class CreateProductsPartialSuccess(JsonModel): + products: List[Product] + """The list of products that were successfully created.""" + + failed: Optional[List[Product]] = None + """The list of products that were not created. + + If this is `None`, then all products were successfully created. + """ + + error: Optional[ApiError] = None + """Error messages for products that were not created. + + If this is `None`, then all products were successfully created. + """ diff --git a/nisystemlink/clients/product/models/_delete_products_partial_success.py b/nisystemlink/clients/product/models/_delete_products_partial_success.py new file mode 100644 index 00000000..4e0b1613 --- /dev/null +++ b/nisystemlink/clients/product/models/_delete_products_partial_success.py @@ -0,0 +1,17 @@ +from typing import List, Optional + +from nisystemlink.clients.core import ApiError +from nisystemlink.clients.core._uplink._json_model import JsonModel + + +class DeleteProductsPartialSuccess(JsonModel): + """The result of deleting multiple products when one or more products could not be deleted.""" + + ids: List[str] + """The IDs of the products that were successfully deleted.""" + + failed: Optional[List[str]] + """The IDs of the products that could not be deleted.""" + + error: Optional[ApiError] + """The error that occurred when deleting the products.""" diff --git a/nisystemlink/clients/product/models/_paged_products.py b/nisystemlink/clients/product/models/_paged_products.py new file mode 100644 index 00000000..7af39716 --- /dev/null +++ b/nisystemlink/clients/product/models/_paged_products.py @@ -0,0 +1,14 @@ +from typing import List, Optional + +from nisystemlink.clients.core._uplink._with_paging import WithPaging +from nisystemlink.clients.product.models import Product + + +class PagedProducts(WithPaging): + """The response for a Products query containing matched products.""" + + products: List[Product] + """A list of all the products in this page.""" + + total_count: Optional[int] + """The total number of products that match the query.""" diff --git a/nisystemlink/clients/product/models/_product.py b/nisystemlink/clients/product/models/_product.py new file mode 100644 index 00000000..23058d65 --- /dev/null +++ b/nisystemlink/clients/product/models/_product.py @@ -0,0 +1,45 @@ +from datetime import datetime +from typing import Dict, List, Optional + +from nisystemlink.clients.core._uplink._json_model import JsonModel + + +class Product(JsonModel): + """Contains information about a product.""" + + id: Optional[str] + """The globally unique id of the product.""" + + part_number: str + """The part number is the unique identifier of a product within a single org. + + Usually the part number refers to a specific revision or version of a given product.""" + + name: Optional[str] + """The name of the product. + + Usually the name is used to refer to several part numbers that all have the same name but + different revisions or versions. + """ + + family: Optional[str] + """The family that that this product belongs to. + + Usually the family is a grouping above product name. A family usually has multiple product + names within it. + """ + + updated_at: Optional[datetime] + """The last time that this product was updated.""" + + file_ids: Optional[List[str]] + """A list of file ids that are attached to this product.""" + + keywords: Optional[List[str]] + """A list of keywords that categorize this product.""" + + properties: Optional[Dict[str, str]] + """A list of custom properties for this product.""" + + workspace: Optional[str] + """The id of the workspace that this product belongs to.""" diff --git a/nisystemlink/clients/product/models/_query_products_request.py b/nisystemlink/clients/product/models/_query_products_request.py new file mode 100644 index 00000000..aca8b919 --- /dev/null +++ b/nisystemlink/clients/product/models/_query_products_request.py @@ -0,0 +1,91 @@ +from enum import Enum +from typing import List, Optional + +from nisystemlink.clients.core._uplink._json_model import JsonModel +from pydantic import Field + + +class ProductField(str, Enum): + """The valid ways to order a product query.""" + + ID = "ID" + FAMILY = "FAMILY" + PART_NUMBER = "PART_NUMBER" + NAME = "NAME" + UPDATED_AT = "UPDATED_AT" + + +class QueryProductsBase(JsonModel): + filter: Optional[str] = None + """ + The product query filter in Dynamic Linq format. + + Allowed properties in the filter are: + - `id`: String for the global identifier of the product + - `partNumber`: String representing the part number of the product + - `name`: String of the product name + - `family`: String for the product family + - `updatedAt`: ISO-8601 formatted UTC timestamp indicating when the product was last updated. + - `keywords`: A list of keyword strings + - `properties`: A dictionary of additional string to string properties + - `fileIds`: A list of string ids for files stored in the file service (`/nifile`) + + See [Dynamic Linq](https://github.com/ni/systemlink-OpenAPI-documents/wiki/Dynamic-Linq-Query-Language) + documentation for more details. + + `"@0"`, `"@1"` etc. can be used in conjunction with the `substitutions` parameter to keep this + query string more simple and reusable. + """ + + substitutions: Optional[List[str]] = None + """String substitutions into the `filter`. + + Makes substitutions in the query filter expression. Substitutions for the query expression are + indicated by non-negative integers that are prefixed with the "at" symbol. Each substitution in + the given expression will be replaced by the element at the corresponding index (zero-based) in + this list. For example, "@0" in the filter expression will be replaced with the element at the + zeroth index of the substitutions list. + """ + + +class QueryProductsRequest(QueryProductsBase): + + order_by: Optional[ProductField] = Field(None, alias="orderBy") + """Specifies the fields to use to sort the products. + + By default, products are sorted by `id` + """ + + descending: Optional[bool] = None + """Specifies whether to return the products in descending order. + + By default, this value is `false` and products are sorted in ascending order. + """ + take: Optional[int] = None + """Maximum number of products to return in the current API response. + + Uses the default if the specified value is negative. The default value is `1000` products. + """ + continuation_token: Optional[str] = None + """Allows users to continue the query at the next product that matches the given criteria. + + To retrieve the next page of products, pass the continuation token from the previous + page in the next request. The service responds with the next page of data and provides a new + continuation token. To paginate results, continue sending requests with the newest continuation + token provided in each response. + """ + + return_count: Optional[bool] = None + """If true, the response will include a count of all products matching the filter. + + By default, this value is `False` and count is not returned. Note that returning the count may + incur performance penalties as the service may have to do a complete walk of the database to + compute count. """ + + +class QueryProductValuesRequest(QueryProductsBase): + field: Optional[ProductField] = None + """The product field to return for this query.""" + + starts_with: Optional[str] = None + """Only return string parameters prefixed by this value (case sensitive).""" diff --git a/nisystemlink/clients/product/utilities/__init__.py b/nisystemlink/clients/product/utilities/__init__.py new file mode 100644 index 00000000..d182fab1 --- /dev/null +++ b/nisystemlink/clients/product/utilities/__init__.py @@ -0,0 +1,3 @@ +from ._file_utilities import get_products_linked_to_file + +# flake8: noqa diff --git a/nisystemlink/clients/product/utilities/_file_utilities.py b/nisystemlink/clients/product/utilities/_file_utilities.py new file mode 100644 index 00000000..70f8189b --- /dev/null +++ b/nisystemlink/clients/product/utilities/_file_utilities.py @@ -0,0 +1,30 @@ +from typing import List + +from nisystemlink.clients.product._product_client import ProductClient +from nisystemlink.clients.product.models._paged_products import PagedProducts +from nisystemlink.clients.product.models._product import Product +from nisystemlink.clients.product.models._query_products_request import ( + QueryProductsRequest, +) + + +def get_products_linked_to_file(client: ProductClient, file_id: str) -> List[Product]: + """Gets a list of all the products that are linked to the file. + + Args: + `client` : The `ProductClient` to use for the request. + `file_id`: The id of the file to query links for. + + Returns: + `List[Product]`: A list of all the products that are linked to the file with `file_id` + """ + query_request = QueryProductsRequest( + filter=f'fileIds.Contains("{file_id}")', take=100 + ) + response: PagedProducts = client.query_products_paged(query_request) + products = response.products + while response.continuation_token: + query_request.continuation_token = response.continuation_token + response = client.query_products_paged(query_request) + products.extend(response.products) + return products diff --git a/nisystemlink/clients/testmonitor/_test_monitor_client.py b/nisystemlink/clients/testmonitor/_test_monitor_client.py index 3af88acd..fd45d74d 100644 --- a/nisystemlink/clients/testmonitor/_test_monitor_client.py +++ b/nisystemlink/clients/testmonitor/_test_monitor_client.py @@ -13,9 +13,20 @@ class TestMonitorClient(BaseClient): # prevent pytest from thinking this is a test class __test__ = False - def __init__(self, configuration: Optional[core.HttpConfiguration]): + def __init__(self, configuration: Optional[core.HttpConfiguration] = None): + """Initialize an instance. + + Args: + configuration: Defines the web server to connect to and information about + how to connect. If not provided, the + :class:`HttpConfigurationManager ` + is used to obtain the configuration. + + Raises: + ApiException: if unable to communicate with the Spec Service. + """ if configuration is None: - configuration = core.JupyterHttpConfiguration() + configuration = core.HttpConfigurationManager.get_configuration() super().__init__(configuration, base_path="/nitestmonitor/v2/") @get("") @@ -26,6 +37,6 @@ def api_info(self) -> models.ApiInfo: Information about available API operations. Raises: - ApiException: if unable to communicate with the `nitestmonitor` service. + ApiException: if unable to communicate with the `ni``/nitestmonitor``` service. """ ... diff --git a/tests/integration/product/test_product_client.py b/tests/integration/product/test_product_client.py new file mode 100644 index 00000000..e4b159e2 --- /dev/null +++ b/tests/integration/product/test_product_client.py @@ -0,0 +1,288 @@ +import uuid +from typing import List + +import pytest +from nisystemlink.clients.core._http_configuration import HttpConfiguration +from nisystemlink.clients.product._product_client import ProductClient +from nisystemlink.clients.product.models import ( + CreateProductsPartialSuccess, + Product, +) +from nisystemlink.clients.product.models._paged_products import PagedProducts +from nisystemlink.clients.product.models._query_products_request import ( + ProductField, + QueryProductsRequest, + QueryProductValuesRequest, +) +from nisystemlink.clients.product.utilities import get_products_linked_to_file + + +@pytest.fixture(scope="class") +def client(enterprise_config: HttpConfiguration) -> ProductClient: + """Fixture to create a ProductClient instance.""" + return ProductClient(enterprise_config) + + +@pytest.fixture +def unique_identifier() -> str: + """Unique product id for this test.""" + product_id = uuid.uuid1().hex + return product_id + + +@pytest.fixture +def create_products(client: ProductClient): + """Fixture to return a factory that creates specs.""" + responses: List[CreateProductsPartialSuccess] = [] + + def _create_products(products: List[Product]) -> CreateProductsPartialSuccess: + response = client.create_products(products) + responses.append(response) + return response + + yield _create_products + + created_products: List[Product] = [] + for response in responses: + if response.products: + created_products = created_products + response.products + client.delete_products(ids=[str(product.id) for product in created_products]) + + +@pytest.mark.integration +@pytest.mark.enterprise +class TestProductClient: + + def test__create_single_product__one_product_created_with_right_field_values( + self, client: ProductClient, create_products, unique_identifier + ): + part_number = unique_identifier + name = "Test Name" + family = "Example Family" + keywords = ["testing"] + properties = {"test_property": "yes"} + product = Product( + part_number=part_number, + name=name, + family=family, + keywords=keywords, + properties=properties, + ) + + response: CreateProductsPartialSuccess = create_products([product]) + assert response is not None + assert len(response.products) == 1 + created_product = response.products[0] + assert created_product.part_number == part_number + assert created_product.name == name + assert created_product.family == family + assert created_product.keywords == keywords + assert created_product.properties == properties + + def test__create_multiple_products__multiple_creates_succeed( + self, client: ProductClient, create_products + ): + products = [ + Product(part_number=uuid.uuid1().hex), + Product(part_number=uuid.uuid1().hex), + ] + response: CreateProductsPartialSuccess = create_products(products) + assert response is not None + assert len(response.products) == 2 + + def test__create_single_product_and_get_products__at_least_one_product_exists( + self, client: ProductClient, create_products, unique_identifier + ): + products = [Product(part_number=unique_identifier)] + create_products(products) + get_response = client.get_products_paged() + assert get_response is not None + assert len(get_response.products) >= 1 + + def test__create_multiple_products_and_get_products_with_take__only_take_returned( + self, client: ProductClient, create_products, unique_identifier + ): + products = [ + Product(part_number=unique_identifier), + Product(part_number=unique_identifier), + ] + create_products(products) + get_response = client.get_products_paged(take=1) + assert get_response is not None + assert len(get_response.products) == 1 + + def test__create_multiple_products_and_get_products_with_count_at_least_one_count( + self, client: ProductClient, create_products, unique_identifier + ): + products = [ + Product(part_number=unique_identifier), + Product(part_number=unique_identifier), + ] + create_products(products) + get_response: PagedProducts = client.get_products_paged(return_count=True) + assert get_response is not None + assert get_response.total_count is not None and get_response.total_count >= 2 + + def test__get_product_by_id__product_matches_expected( + self, client: ProductClient, create_products, unique_identifier + ): + part_number = unique_identifier + products = [Product(part_number=part_number)] + create_response: CreateProductsPartialSuccess = create_products(products) + assert create_response is not None + id = str(create_response.products[0].id) + product = client.get_product(id) + assert product is not None + assert product.part_number == part_number + + def test__query_product_by_part_number__matches_expected( + self, client: ProductClient, create_products, unique_identifier + ): + part_number = unique_identifier + products = [Product(part_number=part_number)] + create_response: CreateProductsPartialSuccess = create_products(products) + assert create_response is not None + query_request = QueryProductsRequest( + filter=f'partNumber="{part_number}"', return_count=True + ) + query_response: PagedProducts = client.query_products_paged(query_request) + assert query_response.total_count == 1 + assert query_response.products[0].part_number == part_number + + def test__query_product_values_for_name__name_matches( + self, client: ProductClient, create_products, unique_identifier + ): + part_number = unique_identifier + test_name = "query values test" + create_response: CreateProductsPartialSuccess = create_products( + [Product(part_number=part_number, name=test_name)] + ) + assert create_response is not None + query_request = QueryProductValuesRequest( + filter=f'partNumber="{part_number}"', field=ProductField.NAME + ) + query_response: List[str] = client.query_product_values(query_request) + assert query_response is not None + assert len(query_response) == 1 + assert query_response[0] == test_name + + def test__update_keywords_with_replace__keywords_replaced( + self, client: ProductClient, create_products, unique_identifier + ): + original_keyword = "originalKeyword" + updated_keyword = "updatedKeyword" + create_response: CreateProductsPartialSuccess = create_products( + [Product(part_number=unique_identifier, keywords=[original_keyword])] + ) + assert create_response is not None + assert len(create_response.products) == 1 + updated_product = create_response.products[0] + updated_product.keywords = [updated_keyword] + update_response = client.update_products([updated_product], replace=True) + assert update_response is not None + assert len(update_response.products) == 1 + assert ( + update_response.products[0].keywords is not None + and updated_keyword in update_response.products[0].keywords + ) + assert original_keyword not in update_response.products[0].keywords + + def test__update_keywords_no_replace__keywords_appended( + self, client: ProductClient, create_products, unique_identifier + ): + original_keyword = "originalKeyword" + additional_keyword = "additionalKeyword" + create_response: CreateProductsPartialSuccess = create_products( + [Product(part_number=unique_identifier, keywords=[original_keyword])] + ) + assert create_response is not None + assert len(create_response.products) == 1 + updated_product = create_response.products[0] + updated_product.keywords = [additional_keyword] + update_response = client.update_products([updated_product], replace=False) + assert update_response is not None + assert len(update_response.products) == 1 + assert ( + update_response.products[0].keywords is not None + and original_keyword in update_response.products[0].keywords + ) + assert ( + update_response.products[0].keywords is not None + and additional_keyword in update_response.products[0].keywords + ) + + def test__update_properties_with_replace__properties_replaced( + self, client: ProductClient, create_products, unique_identifier + ): + new_key = "newKey" + original_properties = {"originalKey": "originalValue"} + new_properties = {new_key: "newValue"} + create_response: CreateProductsPartialSuccess = create_products( + [Product(part_number=unique_identifier, properties=original_properties)] + ) + assert create_response is not None + assert len(create_response.products) == 1 + updated_product = create_response.products[0] + updated_product.properties = new_properties + update_response = client.update_products([updated_product], replace=True) + assert update_response is not None + assert len(update_response.products) == 1 + assert ( + update_response.products[0].properties is not None + and len(update_response.products[0].properties) == 1 + ) + assert new_key in update_response.products[0].properties.keys() + assert ( + update_response.products[0].properties[new_key] == new_properties[new_key] + ) + + def test__update_properties_append__properties_appended( + self, client: ProductClient, create_products, unique_identifier + ): + original_key = "originalKey" + new_key = "newKey" + original_properties = {original_key: "originalValue"} + new_properties = {new_key: "newValue"} + create_response: CreateProductsPartialSuccess = create_products( + [Product(part_number=unique_identifier, properties=original_properties)] + ) + assert create_response is not None + assert len(create_response.products) == 1 + updated_product = create_response.products[0] + updated_product.properties = new_properties + update_response = client.update_products([updated_product], replace=False) + assert update_response is not None + assert len(update_response.products) == 1 + updated_product = update_response.products[0] + assert ( + updated_product.properties is not None + and len(updated_product.properties) == 2 + ) + assert original_key in updated_product.properties.keys() + assert new_key in updated_product.properties.keys() + assert ( + updated_product.properties[original_key] + == original_properties[original_key] + ) + assert updated_product.properties[new_key] == new_properties[new_key] + + def test__query_products_linked_to_files_correct_products_returned( + self, client: ProductClient, create_products + ): + file_id = uuid.uuid1().hex + product_name_with_file = "Has File" + products = [ + Product( + part_number=uuid.uuid1().hex, + name=product_name_with_file, + file_ids=[file_id], + ), + Product(part_number=uuid.uuid1().hex, name="No File Link"), + ] + print(products) + create_response: CreateProductsPartialSuccess = create_products(products) + assert create_response is not None + assert len(create_response.products) == 2 + linked_products = get_products_linked_to_file(client, file_id) + names = [product.name for product in linked_products] + assert product_name_with_file in names diff --git a/tests/integration/testmonitor/test_products.py b/tests/integration/testmonitor/test_testmonitor.py similarity index 100% rename from tests/integration/testmonitor/test_products.py rename to tests/integration/testmonitor/test_testmonitor.py