From 344189b76885937f2893657d7a54bada7e96cde9 Mon Sep 17 00:00:00 2001 From: Andreas Ellewsen <ae@uio.no> Date: Tue, 8 Jun 2021 15:49:06 +0200 Subject: [PATCH 1/2] Add support for ExecuteServerProcessAsynchronously Allows sending of orders (and other formats) to the import service. To make the request have the form the server expects we had to cheat a bit and create a separate Transport class for the import service where we replace the erroneously modified "<" to "<" back. --- tests/conftest.py | 23 +++- tests/fixtures/salgsordre.xml | 133 ++++++++++++++++++++++ tests/fixtures/salgsordre_response.xml | 13 +++ tests/test_client.py | 49 ++++++-- ubw_client/client.py | 152 +++++++++++++++++++++---- ubw_client/models.py | 23 +++- 6 files changed, 358 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/salgsordre.xml create mode 100644 tests/fixtures/salgsordre_response.xml diff --git a/tests/conftest.py b/tests/conftest.py index ec68da2..a677287 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,7 +39,16 @@ def config(base_url): "ressurs": {"headers": {}}, }, }, - "import_service": None, + "import_service": { + "wsdl": "wsdl/importservice.wsdl", + "headers": {}, + "credentials": { + "username": "fake_test_user", + "client": "72", + "password": "fake_test_password", + }, + "alternate_endpoint": "https://example.com/soap/importtjeneste", + }, "transaction_service": None, } @@ -216,6 +225,18 @@ def avgiftskoder_data(): return load_json_file("avgiftskoder.json") +@pytest.fixture +def salgsordre(): + here = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) + return load_file(os.path.join(here, "fixtures", "salgsordre.xml")) + + +@pytest.fixture +def salgsordre_response(): + here = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) + return load_file(os.path.join(here, "fixtures", "salgsordre_response.xml")) + + @pytest.fixture def transaction_report_data(): return load_xml("transaction_report.xml") diff --git a/tests/fixtures/salgsordre.xml b/tests/fixtures/salgsordre.xml new file mode 100644 index 0000000..5155756 --- /dev/null +++ b/tests/fixtures/salgsordre.xml @@ -0,0 +1,133 @@ +<ABWOrder + xsi:schemaLocation="http://services.agresso.com/schema/ABWOrder/2007/12/24 http://services.agresso.com/schema/ABWOrder/2007/12/24/ABWOrder.xsd" + xmlns:agr="http://services.agresso.com/schema/ABWOrder/2007/12/24" + xmlns:agrlib="http://services.agresso.com/schema/ABWSchemaLib/2007/12/24" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <Order> + <OrderNo>1</OrderNo> + <TransType>42</TransType> + <Header> + <InvoiceControl>N</InvoiceControl> + <OrderType>FA</OrderType> + <Vouchertype>SO</Vouchertype> + <Status>N</Status> + <OrderDate>2020-05-19</OrderDate> + <DelivDate>2020-05-16</DelivDate> + <HeaderText></HeaderText> + <FooterText></FooterText> + <Text3>Text3 felt test 0706</Text3> + <Text4>Text4 felt test 0706</Text4> + <Currency>NOK</Currency> + <ExtOrderId>Bilagstekst i regnskapet</ExtOrderId> + <ExtOrderRef>Kundereferanse feks nummer</ExtOrderRef> + <Seller> + <SellerReferences> + <Responsible>9900JSKUN</Responsible> + <SalesMan>9900JSKUN</SalesMan> + </SellerReferences> + </Seller> + <Buyer> + <BuyerNo>1000</BuyerNo> + <BuyerReferences> + <Accountable>Kundereferanse feks navn</Accountable> + </BuyerReferences> + </Buyer> + </Header> + <Details> + <Detail> + <LineNo>1</LineNo> + <BuyerProductCode>3001</BuyerProductCode> + <BuyerProductDescr>Tekst varen/tjenesten som er solgt rad 1 + </BuyerProductDescr> + <Quantity>1</Quantity> + <Price>1000.00</Price> + <LineTotal>1000.00</LineTotal> + <DetailInfo> + <ReferenceCode> + <Code>A0</Code> + <Value>3001</Value> + </ReferenceCode> + <ReferenceCode> + <Code>C1</Code> + <Value>11000000</Value> + </ReferenceCode> + <ReferenceCode> + <Code>B0</Code> + <Value></Value> + </ReferenceCode> + <ReferenceCode> + <Code>T1</Code> + <Value></Value> + </ReferenceCode> + <ReferenceCode> + <Code>A7</Code> + <Value></Value> + </ReferenceCode> + <ReferenceCode> + <Code>BF</Code> + <Value>100001100</Value> + </ReferenceCode> + <ReferenceCode> + <Code>B0</Code> + <Value></Value> + </ReferenceCode> + <ReferenceCode> + <Code>B1</Code> + <Value></Value> + </ReferenceCode> + </DetailInfo> + <ProductSpecification> + <SeqNo>1</SeqNo> + <Info>Tilleggstekst under rad 1</Info> + </ProductSpecification> + </Detail> + <Detail> + <LineNo>2</LineNo> + <BuyerProductCode>3001</BuyerProductCode> + <BuyerProductDescr>Tekst varen/tjenesten som er solgt rad 2 + </BuyerProductDescr> + <Quantity>2</Quantity> + <Price>500.00</Price> + <LineTotal>1000.00</LineTotal> + <DetailInfo> + <ReferenceCode> + <Code>A0</Code> + <Value>3001</Value> + </ReferenceCode> + <ReferenceCode> + <Code>C1</Code> + <Value>11000000</Value> + </ReferenceCode> + <ReferenceCode> + <Code>B0</Code> + <Value></Value> + </ReferenceCode> + <ReferenceCode> + <Code>T1</Code> + <Value></Value> + </ReferenceCode> + <ReferenceCode> + <Code>A7</Code> + <Value></Value> + </ReferenceCode> + <ReferenceCode> + <Code>BF</Code> + <Value>100001100</Value> + </ReferenceCode> + <ReferenceCode> + <Code>B0</Code> + <Value></Value> + </ReferenceCode> + <ReferenceCode> + <Code>B1</Code> + <Value></Value> + </ReferenceCode> + </DetailInfo> + <ProductSpecification> + <SeqNo>1</SeqNo> + <Info>Tilleggstekst under rad 2</Info> + </ProductSpecification> + </Detail> + </Details> + </Order> +</ABWOrder> \ No newline at end of file diff --git a/tests/fixtures/salgsordre_response.xml b/tests/fixtures/salgsordre_response.xml new file mode 100644 index 0000000..0d2dcfd --- /dev/null +++ b/tests/fixtures/salgsordre_response.xml @@ -0,0 +1,13 @@ +<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"> + <s:Body> + <ExecuteServerProcessAsynchronouslyResponse + xmlns="http://services.agresso.com/ImportService/ImportV200606" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <ExecuteServerProcessAsynchronouslyResult> + <OrderNumber>39935</OrderNumber> + <InputTableName>algbatchinput</InputTableName> + <Namespace>http://services.agresso.com/schema/ABWOrder/2007/12/24</Namespace> + </ExecuteServerProcessAsynchronouslyResult> + </ExecuteServerProcessAsynchronouslyResponse> + </s:Body> +</s:Envelope> \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index a4db4ac..331a36a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,12 +6,12 @@ import pytest from ubw_client import ClientError from ubw_client.client import ( Endpoints, - NotFoundException, - _create_transaction_report, + _create_server_process_result, _map_transaction, ) from ubw_client.models import ( Arbeidsordre, + AsynchronousOutput, Begrep, Begrepsverdi, Bruker, @@ -210,7 +210,7 @@ def test_get_perioder_empty(client, base_url, requests_mock): json=[], ) received = client.get_perioder(company_id, period_type=period_type) - assert None == received + assert None is received def test_get_arbeidsordre(client, base_url, requests_mock, arbeidsordre_data): @@ -250,7 +250,6 @@ def test_get_gl07logs(client, base_url, requests_mock, gl07logs_data): def test_gl07logchecks(client, base_url, requests_mock, gl07logchecks_data): batch_id = "19103133" company_id = "72" - params = {"filter": f"batchId eq '{batch_id}'"} requests_mock.get( f"https://example.com/gl07logchecks/{company_id}?filter=batchId+eq+%27{batch_id}%27", json=gl07logchecks_data, @@ -329,10 +328,8 @@ def test_get_avgiftskode(client, requests_mock, avgiftskoder_data): assert single_got == expected_single -def test_get_transaction_report( - client, base_url, requests_mock, transaction_report_data -): - report = _create_transaction_report( +def test_get_transaction_report(client, base_url, transaction_report_data): + report = _create_server_process_result( transaction_report_data["Envelope"]["Body"]["GetResultResponse"][ "GetResultResult" ] @@ -402,7 +399,9 @@ def test_ubw_client_config(): == "https://example.com/bilagstyper/v1/72/TT" ) assert endpoints.get_ressurser("72") == "https://example.com/ressurser/v1/72" - assert endpoints.get_ressurser("72", 12) == "https://example.com/ressurser/v1/72/12" + assert ( + endpoints.get_ressurser("72", "12") == "https://example.com/ressurser/v1/72/12" + ) def test_map_transaction(): @@ -509,3 +508,35 @@ def test_get_ressurser(client, base_url, requests_mock, ressurser_data): assert len(received) == 3 for i in range(len(received)): assert received[i] == expected[i] + + +def test_execute_server_process_asynchronously( + client, requests_mock, salgsordre, salgsordre_response +): + """ + Ensure client handles input and response correctly for the + execute_server_process_asynchronously method. + """ + requests_mock.post( + "https://example.com/soap/importtjeneste", + headers={"Content-Type": "text/xml; charset=utf-8"}, + text=salgsordre_response, + ) + received = client.execute_server_process_asynchronously( + server_process_id="LG04", + menu_id="SO103", + variant=159, + xml=salgsordre, + ) + + salgsordre_response_object = AsynchronousOutput( + order_number=39935, + input_table_name="algbatchinput", + namespace="http://services.agresso.com/schema/ABWOrder/2007/12/24", + response_list=[], + ) + expected = salgsordre_response_object + assert received.order_number == expected.order_number + assert received.input_table_name == expected.input_table_name + assert received.namespace == expected.namespace + assert received.response_list == expected.response_list diff --git a/ubw_client/client.py b/ubw_client/client.py index 46a5ca5..0eabc2f 100644 --- a/ubw_client/client.py +++ b/ubw_client/client.py @@ -1,14 +1,15 @@ -import logging -import logging.config -import requests import datetime - +import logging.config import typing +from urllib.parse import urljoin, urlparse + +import requests import zeep import zeep.exceptions +from lxml import etree +from zeep import Transport from zeep.helpers import serialize_object as zeep_serialize from zeep.plugins import HistoryPlugin -from urllib.parse import urljoin, urlparse from . import models @@ -173,22 +174,34 @@ class Endpoints: ) -def _create_transaction_report( +def _get_list(response_dict, a, b): + """ + Extract list from response dict. + + >>> _get_list({"a": {"b": ["c", "d", "e"]}}, "a", "b") + ['c', 'd', 'e'] + >>> _get_list({"a": {"b": "c"}}, "a", "b") + ['c'] + >>> _get_list({"a": {"b": "c"}}, "a", "d") + [] + """ + + x = (response_dict.get(a) or {}).get(b) + return x if isinstance(x, list) else [x] if x else [] + + +def _create_server_process_result( transaction_result_response, ) -> models.ServerProcessResult: - """Create transaction report from dict or zeep object""" + """Create a ServerProcessResult from dict or zeep object""" response_dict = ( transaction_result_response if isinstance(transaction_result_response, dict) else zeep_serialize(transaction_result_response) ) - def get_list(a, b): - x = (response_dict.get(a) or {}).get(b) - return x if isinstance(x, list) else [x] if x else [] - - file_list = get_list("B64FileList", "B64File") - response_list = get_list("ResponseList", "Response") + file_list = _get_list(response_dict, "B64FileList", "B64File") + response_list = _get_list(response_dict, "ResponseList", "Response") data = { "error_message": response_dict.get("ErrorMessage"), "file_list": file_list, @@ -198,6 +211,26 @@ def _create_transaction_report( return models.ServerProcessResult.from_dict(data) +def _create_asynchronous_output( + asynchronous_output_response, +) -> models.AsynchronousOutput: + """Create an AsynchronousOutput from dict or zeep object""" + response_dict = ( + asynchronous_output_response + if isinstance(asynchronous_output_response, dict) + else zeep_serialize(asynchronous_output_response) + ) + + data = { + "order_number": response_dict.get("OrderNumber"), + "input_table_name": response_dict.get("InputTableName"), + "namespace": response_dict.get("Namespace"), + "response_list": _get_list(response_dict, "ResponseList", "Response"), + } + + return models.AsynchronousOutput(**data) + + def _map_transaction(transaction: models.Transaction) -> typing.Dict[str, typing.Any]: date_fields = [ "arrival_date", @@ -235,6 +268,20 @@ def _map_transaction(transaction: models.Transaction) -> typing.Dict[str, typing return mapped +class CDATATransport(Transport): + """ + Transport class used by the import service to preserve the contents of the xml + field used by ExecuteServerProcessAsynchronously. + """ + + def post_xml(self, address, envelope, headers): + """This method is needed so that we preserve the tags in CDATA""" + message = etree.tostring(envelope, encoding="utf-8") + message = message.replace(b"<", b"<") + message = message.replace(b">", b">") + return self.post(address, message, headers) + + class UBWClient: def __init__(self, config: models.UbwClientConfig) -> None: self.config = config @@ -248,14 +295,17 @@ class UBWClient: self.history = HistoryPlugin() self.import_client, self.import_service = self._build_soap_service( - config.import_service + config=config.import_service, transport=CDATATransport ) + self.transaction_client, self.transaction_service = self._build_soap_service( - config.transaction_service + config=config.transaction_service, transport=Transport ) def _build_soap_service( - self, config: typing.Optional[models.UbwSoapService] + self, + config: typing.Optional[models.UbwSoapService], + transport: typing.Callable, ) -> typing.Tuple[ typing.Optional[zeep.client.Client], typing.Optional[zeep.proxy.ServiceProxy] ]: @@ -264,10 +314,11 @@ class UBWClient: session = requests.Session() session.headers.update(config.headers) - transport = zeep.transports.Transport(session=session) client = zeep.Client( - wsdl=config.wsdl, transport=transport, plugins=[self.history] + wsdl=config.wsdl, + transport=transport(session=session), + plugins=[self.history], ) if config.alternate_endpoint: @@ -295,8 +346,8 @@ class UBWClient: raise ClientError("Transaction API is not configured") batch_inputs = [] + type_factory = self.transaction_client.type_factory("ns0") for t in transactions.__root__: - type_factory = self.transaction_client.type_factory("ns0") batch_inputs.append( type_factory.BatchInputDTO( **_map_transaction(t), @@ -340,10 +391,10 @@ class UBWClient: batch_input_array = {"BatchInputDTO": batch_inputs} try: r = self.transaction_service.SaveTransactions( - batch_input_array, - batch_input_process_type, - period, - batch_id, + batchInput=batch_input_array, + parameters=batch_input_process_type, + period=period, + batchId=batch_id, credentials=dict(self.config.transaction_service.credentials), ) except zeep.exceptions.Fault as e: @@ -364,6 +415,59 @@ class UBWClient: except requests.HTTPError as e: return none_or_error(e) + def execute_server_process_asynchronously( + self, + variant: int, + xml: str, + menu_id: str = zeep.xsd.SkipValue, + process_parameters: typing.Tuple[str, str] = zeep.xsd.SkipValue, + server_process_id: str = zeep.xsd.SkipValue, + stylesheet: str = zeep.xsd.SkipValue, + pipeline_associated_name: str = zeep.xsd.SkipValue, + ) -> models.AsynchronousOutput: + """ + Method for using the ExecuteServerProcessAsynchronously of the Import service + + TODO: Figure out a nice way of sending the xml field without having to wrap + in CDATA, and use a special transport class for the import_client + """ + type_factory = self.import_client.type_factory("ns0") + if process_parameters is not zeep.xsd.SkipValue: + parameter_list = ( + type_factory.ArrayOfParameter( + [ + type_factory.Parameter( + Name=process_parameters[0], Value=process_parameters[1] + ) + ] + ), + ) + else: + parameter_list = zeep.xsd.SkipValue + + server_process_input = type_factory.ServerProcessInput( + ServerProcessId=server_process_id, + MenuId=menu_id, + Variant=variant, + Xml=f"<![CDATA[{xml}]]>", + Stylesheet=stylesheet, + ParameterList=parameter_list, + PipelineAssociatedName=pipeline_associated_name, + ) + try: + asynchronous_output = ( + self.import_service.ExecuteServerProcessAsynchronously( + input=server_process_input, + credentials=dict(self.config.import_service.credentials), + ) + ) + output = _create_asynchronous_output(asynchronous_output) + output.soap_response = models.SoapResponse(**self.history.last_received) + return output + + except zeep.exceptions.Fault as e: + raise ClientError() from e + def get_transaction_report( self, server_process_id: int, order_nr: int ) -> typing.Optional[models.ServerProcessResult]: @@ -374,7 +478,7 @@ class UBWClient: credentials=dict(self.config.import_service.credentials), ) - report = _create_transaction_report(transaction_result_resp) + report = _create_server_process_result(transaction_result_resp) report.soap_response = models.SoapResponse(**self.history.last_received) return report diff --git a/ubw_client/models.py b/ubw_client/models.py index 4f0fe80..099ff29 100644 --- a/ubw_client/models.py +++ b/ubw_client/models.py @@ -627,7 +627,7 @@ class Bruker(BaseModel): role_and_company: typing.Optional[typing.List[RoleAndCompany]] @pydantic.root_validator(pre=True) - def convert_role_and_company(cls, values): + def convert_role_and_company(cls, values): # noqa: N805 if "rolesAndCompanies" in values: values["roleAndCompany"] = values["rolesAndCompanies"] del values["rolesAndCompanies"] @@ -763,6 +763,27 @@ class SoapResponse(BaseModel): allow_population_by_field_name = True +class Response(BaseModel): + return_code: int + status: typing.Optional[str] + + class Config: + alias_generator = to_upper_camel + allow_population_by_field_name = True + + +class AsynchronousOutput(BaseModel): + order_number: int + input_table_name: typing.Optional[str] + namespace: typing.Optional[str] + response_list: typing.Optional[typing.List[Response]] + soap_response: typing.Optional[SoapResponse] + + class Config: + alias_generator = to_upper_camel + allow_population_by_field_name = True + + class ServerProcessResult(BaseModel): error_message: typing.Optional[str] file_list: typing.List[ServerProcessFile] -- GitLab From dadb19db6c8fdaa5da81d3a3831b5c59a4c4a892 Mon Sep 17 00:00:00 2001 From: Andreas Ellewsen <ae@uio.no> Date: Fri, 11 Jun 2021 09:39:01 +0200 Subject: [PATCH 2/2] Use ElementTree from defusedxml instead of lxml --- requirements.txt | 2 +- ubw_client/client.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6bd27d5..4f9adaa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ requests pydantic zeep -lxml +defusedxml diff --git a/ubw_client/client.py b/ubw_client/client.py index 0eabc2f..e99312e 100644 --- a/ubw_client/client.py +++ b/ubw_client/client.py @@ -6,7 +6,7 @@ from urllib.parse import urljoin, urlparse import requests import zeep import zeep.exceptions -from lxml import etree +from defusedxml import ElementTree from zeep import Transport from zeep.helpers import serialize_object as zeep_serialize from zeep.plugins import HistoryPlugin @@ -276,7 +276,7 @@ class CDATATransport(Transport): def post_xml(self, address, envelope, headers): """This method is needed so that we preserve the tags in CDATA""" - message = etree.tostring(envelope, encoding="utf-8") + message = ElementTree.tostring(envelope, encoding="utf-8") message = message.replace(b"<", b"<") message = message.replace(b">", b">") return self.post(address, message, headers) -- GitLab