diff --git a/mpt_api_client/resources/catalog/product_terms.py b/mpt_api_client/resources/catalog/product_terms.py new file mode 100644 index 00000000..e3ac9b6b --- /dev/null +++ b/mpt_api_client/resources/catalog/product_terms.py @@ -0,0 +1,43 @@ +from mpt_api_client.http import AsyncService, CreateMixin, DeleteMixin, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + UpdateMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin + + +class Term(Model): + """Term resource.""" + + +class TermServiceConfig: + """Term service configuration.""" + + _endpoint = "/public/v1/catalog/products/{product_id}/terms" + _model_class = Term + _collection_key = "data" + + +class TermService( + CreateMixin[Term], + DeleteMixin, + UpdateMixin[Term], + PublishableMixin[Term], + Service[Term], + TermServiceConfig, +): + """Term service.""" + + +class AsyncTermService( + AsyncCreateMixin[Term], + AsyncDeleteMixin, + AsyncUpdateMixin[Term], + AsyncPublishableMixin[Term], + AsyncService[Term], + TermServiceConfig, +): + """Async Term service.""" diff --git a/mpt_api_client/resources/catalog/products.py b/mpt_api_client/resources/catalog/products.py index 850e1ed6..6dd7f82e 100644 --- a/mpt_api_client/resources/catalog/products.py +++ b/mpt_api_client/resources/catalog/products.py @@ -11,6 +11,14 @@ AsyncPublishableMixin, PublishableMixin, ) +from mpt_api_client.resources.catalog.product_terms import ( + AsyncTermService, + TermService, +) +from mpt_api_client.resources.catalog.products_documents import ( + AsyncDocumentService, + DocumentService, +) from mpt_api_client.resources.catalog.products_item_groups import ( AsyncItemGroupsService, ItemGroupsService, @@ -27,6 +35,10 @@ AsyncParametersService, ParametersService, ) +from mpt_api_client.resources.catalog.products_templates import ( + AsyncTemplatesService, + TemplatesService, +) class Product(Model): @@ -69,12 +81,28 @@ def media(self, product_id: str) -> MediaService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def documents(self, product_id: str) -> DocumentService: + """Return documents service.""" + return DocumentService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) + def product_parameters(self, product_id: str) -> ParametersService: """Return product_parameters service.""" return ParametersService( http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def templates(self, product_id: str) -> TemplatesService: + """Return templates service.""" + return TemplatesService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) + + def terms(self, product_id: str) -> TermService: + """Return terms service.""" + return TermService(http_client=self.http_client, endpoint_params={"product_id": product_id}) + class AsyncProductsService( AsyncCreateMixin[Product], @@ -104,8 +132,26 @@ def media(self, product_id: str) -> AsyncMediaService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def documents(self, product_id: str) -> AsyncDocumentService: + """Return documents service.""" + return AsyncDocumentService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) + def product_parameters(self, product_id: str) -> AsyncParametersService: """Return product_parameters service.""" return AsyncParametersService( http_client=self.http_client, endpoint_params={"product_id": product_id} ) + + def templates(self, product_id: str) -> AsyncTemplatesService: + """Return templates service.""" + return AsyncTemplatesService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) + + def terms(self, product_id: str) -> AsyncTermService: + """Return terms service.""" + return AsyncTermService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) diff --git a/mpt_api_client/resources/catalog/products_documents.py b/mpt_api_client/resources/catalog/products_documents.py new file mode 100644 index 00000000..80da5015 --- /dev/null +++ b/mpt_api_client/resources/catalog/products_documents.py @@ -0,0 +1,143 @@ +import json +from typing import override + +from httpx import Response +from httpx._types import FileTypes + +from mpt_api_client.http import AsyncService, CreateMixin, DeleteMixin, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + UpdateMixin, +) +from mpt_api_client.models import FileModel, Model, ResourceData +from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin + + +def _json_to_file_payload(resource_data: ResourceData) -> bytes: + return json.dumps( + resource_data, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + + +class Document(Model): + """Document resource.""" + + +class DocumentServiceConfig: + """Document service configuration.""" + + _endpoint = "/public/v1/catalog/products/{product_id}/documents" + _model_class = Document + _collection_key = "data" + + +class DocumentService( + CreateMixin[Document], + DeleteMixin, + UpdateMixin[Document], + PublishableMixin[Document], + Service[Document], + DocumentServiceConfig, +): + """Document service.""" + + @override + def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> Document: + """Create Document resource. + + Include the document as a file or add an url in resource_data to be uploaded. + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + Document resource. + """ + files = files or {} + + if resource_data: + files["_attachment_data"] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = self.http_client.post(self.endpoint, files=files) + response.raise_for_status() + return Document.from_response(response) + + def download(self, document_id: str) -> FileModel: + """Download the document file for the given document ID. + + Args: + document_id: Document ID. + + Returns: + Document file. + """ + response: Response = self._resource_do_request( + document_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) + + +class AsyncDocumentService( + AsyncCreateMixin[Document], + AsyncDeleteMixin, + AsyncUpdateMixin[Document], + AsyncPublishableMixin[Document], + AsyncService[Document], + DocumentServiceConfig, +): + """Document service.""" + + @override + async def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> Document: + """Create Document resource. + + Include the document as a file or add an url in resource_data to be uploaded. + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + Document resource. + """ + files = files or {} + + if resource_data: + files["_attachment_data"] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = await self.http_client.post(self.endpoint, files=files) + response.raise_for_status() + return Document.from_response(response) + + async def download(self, document_id: str) -> FileModel: + """Download the document file for the given document ID. + + Args: + document_id: Document ID. + + Returns: + Document file. + """ + response = await self._resource_do_request( + document_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) diff --git a/mpt_api_client/resources/catalog/products_templates.py b/mpt_api_client/resources/catalog/products_templates.py new file mode 100644 index 00000000..c7a0b461 --- /dev/null +++ b/mpt_api_client/resources/catalog/products_templates.py @@ -0,0 +1,40 @@ +from mpt_api_client.http import AsyncService, CreateMixin, DeleteMixin, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + UpdateMixin, +) +from mpt_api_client.models import Model + + +class Template(Model): + """Template resource.""" + + +class TemplatesServiceConfig: + """Templates service configuration.""" + + _endpoint = "/public/v1/catalog/products/{product_id}/templates" + _model_class = Template + _collection_key = "data" + + +class TemplatesService( + CreateMixin[Template], + DeleteMixin, + UpdateMixin[Template], + Service[Template], + TemplatesServiceConfig, +): + """Templates service.""" + + +class AsyncTemplatesService( + AsyncCreateMixin[Template], + AsyncDeleteMixin, + AsyncUpdateMixin[Template], + AsyncService[Template], + TemplatesServiceConfig, +): + """Templates service.""" diff --git a/setup.cfg b/setup.cfg index f67d1233..15c60b8f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,6 +39,9 @@ per-file-ignores = mpt_api_client/resources/catalog/products_parameter_groups.py: WPS215 mpt_api_client/resources/catalog/products_parameters.py: WPS215 mpt_api_client/resources/catalog/products_media.py: WPS215 + mpt_api_client/resources/catalog/products_documents.py: WPS215 + mpt_api_client/resources/catalog/products_templates.py: WPS215 + mpt_api_client/resources/catalog/product_terms.py: WPS215 tests/http/test_async_service.py: WPS204 WPS202 tests/http/test_service.py: WPS204 WPS202 diff --git a/tests/resources/catalog/test_product_terms.py b/tests/resources/catalog/test_product_terms.py new file mode 100644 index 00000000..343d4737 --- /dev/null +++ b/tests/resources/catalog/test_product_terms.py @@ -0,0 +1,40 @@ +import pytest + +from mpt_api_client.resources.catalog.product_terms import ( + AsyncTermService, + TermService, +) + + +@pytest.fixture +def term_service(http_client): + return TermService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_term_service(async_http_client): + return AsyncTermService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +def test_endpoint(term_service): + assert term_service.endpoint == "/public/v1/catalog/products/PRD-001/terms" + + +def test_async_endpoint(async_term_service): + assert async_term_service.endpoint == "/public/v1/catalog/products/PRD-001/terms" + + +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "review", "publish", "unpublish"] +) +def test_methods_present(term_service, method): + assert hasattr(term_service, method) + + +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "review", "publish", "unpublish"] +) +def test_async_methods_present(async_term_service, method): + assert hasattr(async_term_service, method) diff --git a/tests/resources/catalog/test_products.py b/tests/resources/catalog/test_products.py index 7d30ec00..787f5283 100644 --- a/tests/resources/catalog/test_products.py +++ b/tests/resources/catalog/test_products.py @@ -1,6 +1,14 @@ import pytest +from mpt_api_client.resources.catalog.product_terms import ( + AsyncTermService, + TermService, +) from mpt_api_client.resources.catalog.products import AsyncProductsService, ProductsService +from mpt_api_client.resources.catalog.products_documents import ( + AsyncDocumentService, + DocumentService, +) from mpt_api_client.resources.catalog.products_item_groups import ( AsyncItemGroupsService, ItemGroupsService, @@ -17,6 +25,10 @@ AsyncParametersService, ParametersService, ) +from mpt_api_client.resources.catalog.products_templates import ( + AsyncTemplatesService, + TemplatesService, +) @pytest.fixture @@ -49,7 +61,10 @@ def test_async_mixins_present(async_products_service, method): ("item_groups", ItemGroupsService), ("parameter_groups", ParameterGroupsService), ("media", MediaService), + ("documents", DocumentService), ("product_parameters", ParametersService), + ("templates", TemplatesService), + ("terms", TermService), ], ) def test_property_services(products_service, service_method, expected_service_class): @@ -65,7 +80,10 @@ def test_property_services(products_service, service_method, expected_service_cl ("item_groups", AsyncItemGroupsService), ("parameter_groups", AsyncParameterGroupsService), ("media", AsyncMediaService), + ("documents", AsyncDocumentService), ("product_parameters", AsyncParametersService), + ("templates", AsyncTemplatesService), + ("terms", AsyncTermService), ], ) def test_async_property_services(async_products_service, service_method, expected_service_class): diff --git a/tests/resources/catalog/test_products_documents.py b/tests/resources/catalog/test_products_documents.py new file mode 100644 index 00000000..2d8a3c2c --- /dev/null +++ b/tests/resources/catalog/test_products_documents.py @@ -0,0 +1,190 @@ +import io + +import httpx +import pytest +import respx + +from mpt_api_client.resources.catalog.products_documents import ( + AsyncDocumentService, + DocumentService, +) + + +@pytest.fixture +def document_service(http_client): + return DocumentService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_document_service(async_http_client): + return AsyncDocumentService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +def test_endpoint(document_service): + assert document_service.endpoint == "/public/v1/catalog/products/PRD-001/documents" + + +def test_async_endpoint(async_document_service): + assert async_document_service.endpoint == "/public/v1/catalog/products/PRD-001/documents" + + +async def test_async_create(async_document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = await async_document_service.create( + {"name": "Product document"}, files=files + ) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_attachment_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product document"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +async def test_async_create_no_data(async_document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = await async_document_service.create(files=files) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +async def test_async_download(async_document_service): + document_content = b"PDF file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents/DOC-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; ' + 'filename="product_document.pdf"', + }, + content=document_content, + ) + ) + + file_model = await async_document_service.download("DOC-456") + + assert mock_route.called + assert file_model.response.status_code == 200 + assert file_model.response.content == document_content + assert file_model.filename == "product_document.pdf" + + +def test_create(document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = document_service.create({"name": "Product document"}, files=files) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_attachment_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product document"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +def test_create_no_data(document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = document_service.create(files=files) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +def test_download(document_service): + document_content = b"PDF file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents/DOC-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; ' + 'filename="product_document.pdf"', + }, + content=document_content, + ) + ) + + file_model = document_service.download("DOC-456") + + assert mock_route.called + assert file_model.response.status_code == 200 + assert file_model.response.content == document_content + assert file_model.filename == "product_document.pdf" diff --git a/tests/resources/catalog/test_products_templates.py b/tests/resources/catalog/test_products_templates.py new file mode 100644 index 00000000..c175c4e8 --- /dev/null +++ b/tests/resources/catalog/test_products_templates.py @@ -0,0 +1,36 @@ +import pytest + +from mpt_api_client.resources.catalog.products_templates import ( + AsyncTemplatesService, + TemplatesService, +) + + +@pytest.fixture +def templates_service(http_client): + return TemplatesService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_templates_service(async_http_client): + return AsyncTemplatesService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +def test_endpoint(templates_service): + assert templates_service.endpoint == "/public/v1/catalog/products/PRD-001/templates" + + +def test_async_endpoint(async_templates_service): + assert async_templates_service.endpoint == "/public/v1/catalog/products/PRD-001/templates" + + +@pytest.mark.parametrize("method", ["get", "create", "delete", "update", "fetch_page", "iterate"]) +def test_methods_present(templates_service, method): + assert hasattr(templates_service, method) + + +@pytest.mark.parametrize("method", ["get", "create", "delete", "update", "fetch_page", "iterate"]) +def test_async_methods_present(async_templates_service, method): + assert hasattr(async_templates_service, method)