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 "&lt;" 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"&lt;", b"<")
+        message = message.replace(b"&gt;", 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"&lt;", b"<")
         message = message.replace(b"&gt;", b">")
         return self.post(address, message, headers)
-- 
GitLab