diff --git a/pyproject.toml b/pyproject.toml index 972e85459..ff8f1150a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "viadot2" -version = "2.1.14" +version = "2.1.15" description = "A simple data ingestion library to guide data flows from some places to other places." authors = [ { name = "acivitillo", email = "acivitillo@dyvenia.com" }, @@ -32,6 +32,8 @@ dependencies = [ "pyarrow>=10.0, <10.1.0", # numpy>=2.0 is not compatible with the old pyarrow v10.x. "numpy>=1.23.4, <2.0", + "defusedxml>=0.7.1", + ] requires-python = ">=3.10" readme = "README.md" diff --git a/requirements-dev.lock b/requirements-dev.lock index 4b7dd6109..32c31d881 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -7,7 +7,6 @@ # all-features: false # with-sources: false # generate-hashes: false -# universal: false -e file:. aiohappyeyeballs==2.4.0 @@ -109,6 +108,7 @@ decorator==5.1.1 defusedxml==0.7.1 # via cairosvg # via nbconvert + # via viadot2 dnspython==2.6.1 # via email-validator docker==7.1.0 diff --git a/requirements.lock b/requirements.lock index 50485ed67..0deb8756d 100644 --- a/requirements.lock +++ b/requirements.lock @@ -7,7 +7,6 @@ # all-features: false # with-sources: false # generate-hashes: false -# universal: false -e file:. aiolimiter==1.1.0 @@ -64,6 +63,8 @@ cryptography==43.0.0 # via prefect dateparser==1.2.0 # via prefect +defusedxml==0.7.1 + # via viadot2 dnspython==2.6.1 # via email-validator docker==7.1.0 diff --git a/src/viadot/exceptions.py b/src/viadot/exceptions.py index f3d7b818b..5a69383dd 100644 --- a/src/viadot/exceptions.py +++ b/src/viadot/exceptions.py @@ -17,6 +17,10 @@ class DBDataAccessError(Exception): pass +class DataRangeError(Exception): + pass + + class TableDoesNotExistError(Exception): def __init__( self, diff --git a/src/viadot/orchestration/prefect/flows/__init__.py b/src/viadot/orchestration/prefect/flows/__init__.py index 0b314c819..3e37cb23f 100644 --- a/src/viadot/orchestration/prefect/flows/__init__.py +++ b/src/viadot/orchestration/prefect/flows/__init__.py @@ -5,6 +5,7 @@ from .duckdb_to_parquet import duckdb_to_parquet from .duckdb_to_sql_server import duckdb_to_sql_server from .duckdb_transform import duckdb_transform +from .epicor_to_parquet import epicor_to_parquet from .exchange_rates_to_adls import exchange_rates_to_adls from .exchange_rates_to_databricks import exchange_rates_to_databricks from .genesys_to_adls import genesys_to_adls @@ -29,6 +30,7 @@ "duckdb_to_parquet", "duckdb_to_sql_server", "duckdb_transform", + "epicor_to_parquet", "exchange_rates_to_adls", "exchange_rates_to_databricks", "genesys_to_adls", diff --git a/src/viadot/orchestration/prefect/flows/epicor_to_parquet.py b/src/viadot/orchestration/prefect/flows/epicor_to_parquet.py new file mode 100644 index 000000000..e59d898a1 --- /dev/null +++ b/src/viadot/orchestration/prefect/flows/epicor_to_parquet.py @@ -0,0 +1,93 @@ +"""Flows for downloading data from Epicor Prelude API to Parquet file.""" + +from typing import Literal + +from prefect import flow + +from viadot.orchestration.prefect.tasks import epicor_to_df +from viadot.orchestration.prefect.tasks.task_utils import df_to_parquet + + +@flow( + name="extract--epicor--parquet", + description="Extract data from Epicor Prelude API and load it into Parquet file", + retries=1, + retry_delay_seconds=60, +) +def epicor_to_parquet( + path: str, + base_url: str, + filters_xml: str, + if_exists: Literal["append", "replace", "skip"] = "replace", + validate_date_filter: bool = True, + start_date_field: str = "BegInvoiceDate", + end_date_field: str = "EndInvoiceDate", + epicor_credentials_secret: str | None = None, + epicor_config_key: str | None = None, +) -> None: + """Download a pandas `DataFrame` from Epicor Prelude API load it into Parquet file. + + Args: + path (str): Path to Parquet file, where the data will be located. + Defaults to None. + base_url (str, required): Base url to Epicor. + filters_xml (str, required): Filters in form of XML. The date filter + is required. + if_exists (Literal["append", "replace", "skip"], optional): Information what + has to be done, if the file exists. Defaults to "replace" + validate_date_filter (bool, optional): Whether or not validate xml date filters. + Defaults to True. + start_date_field (str, optional) The name of filters field containing + start date. Defaults to "BegInvoiceDate". + end_date_field (str, optional) The name of filters field containing end date. + Defaults to "EndInvoiceDate". + epicor_credentials_secret (str, optional): The name of the secret storing + the credentials. Defaults to None. + More info on: https://docs.prefect.io/concepts/blocks/ + epicor_config_key (str, optional): The key in the viadot config holding relevant + credentials. Defaults to None. + + Examples: + >>> epicor_to_parquet( + >>> path = "my_parquet.parquet", + >>> base_url = "/api/data/import/ORDER.QUERY", + >>> filters_xml = " + >>> + >>> 001 + >>> + >>> + >>> + >>> + >>> + >>> + >>> + >>> + >>> + >>> + >>> + >>> {yesterday} + >>> {yesterday} + >>> + >>> + >>> + >>> + >>> + >>> ", + >>> epicor_config_key = "epicor" + >>> ) + """ + df = epicor_to_df( + base_url=base_url, + filters_xml=filters_xml, + validate_date_filter=validate_date_filter, + start_date_field=start_date_field, + end_date_field=end_date_field, + credentials_secret=epicor_credentials_secret, + config_key=epicor_config_key, + ) + + return df_to_parquet( + df=df, + path=path, + if_exists=if_exists, + ) diff --git a/src/viadot/orchestration/prefect/tasks/__init__.py b/src/viadot/orchestration/prefect/tasks/__init__.py index cbe8f7276..8e1053f08 100644 --- a/src/viadot/orchestration/prefect/tasks/__init__.py +++ b/src/viadot/orchestration/prefect/tasks/__init__.py @@ -6,6 +6,7 @@ from .databricks import df_to_databricks from .dbt import dbt_task from .duckdb import duckdb_query +from .epicor import epicor_to_df from .exchange_rates import exchange_rates_to_df from .genesys import genesys_to_df from .git import clone_repo @@ -32,6 +33,7 @@ "df_to_databricks", "dbt_task", "duckdb_query", + "epicor_to_df", "exchange_rates_to_df", "genesys_to_df", "clone_repo", diff --git a/src/viadot/orchestration/prefect/tasks/epicor.py b/src/viadot/orchestration/prefect/tasks/epicor.py new file mode 100644 index 000000000..2248ffc4b --- /dev/null +++ b/src/viadot/orchestration/prefect/tasks/epicor.py @@ -0,0 +1,64 @@ +"""Task for downloading data from Epicor Prelude API.""" + +import pandas as pd +from prefect import task +from prefect.logging import get_run_logger + +from viadot.config import get_source_credentials +from viadot.orchestration.prefect.exceptions import MissingSourceCredentialsError +from viadot.orchestration.prefect.utils import get_credentials +from viadot.sources.epicor import Epicor + + +@task(retries=3, retry_delay_seconds=10, timeout_seconds=60 * 60 * 3) +def epicor_to_df( + base_url: str, + filters_xml: str, + validate_date_filter: bool = True, + start_date_field: str = "BegInvoiceDate", + end_date_field: str = "EndInvoiceDate", + credentials_secret: str | None = None, + config_key: str | None = None, +) -> pd.DataFrame: + """Load the data from Epicor Prelude API into a pandas DataFrame. + + Args: + base_url (str, required): Base url to Epicor. + filters_xml (str, required): Filters in form of XML. The date filter + is required. + validate_date_filter (bool, optional): Whether or not validate xml date filters. + Defaults to True. + start_date_field (str, optional) The name of filters field containing + start date. Defaults to "BegInvoiceDate". + end_date_field (str, optional) The name of filters field containing end date. + Defaults to "EndInvoiceDate". + credentials_secret (str, optional): The name of the secret storing + the credentials. Defaults to None. + More info on: https://docs.prefect.io/concepts/blocks/ + config_key (str, optional): The key in the viadot config holding relevant + credentials. Defaults to None. + + """ + if not (credentials_secret or config_key): + raise MissingSourceCredentialsError + + logger = get_run_logger() + + credentials = get_source_credentials(config_key) or get_credentials( + credentials_secret + ) + epicor = Epicor( + credentials=credentials, + base_url=base_url, + validate_date_filter=validate_date_filter, + start_date_field=start_date_field, + end_date_field=end_date_field, + ) + df = epicor.to_df(filters_xml=filters_xml) + nrows = df.shape[0] + ncols = df.shape[1] + + logger.info( + f"Successfully downloaded {nrows} rows and {ncols} columns of data to a DataFrame." + ) + return df diff --git a/src/viadot/sources/__init__.py b/src/viadot/sources/__init__.py index 0d77c1bf2..92c520f58 100644 --- a/src/viadot/sources/__init__.py +++ b/src/viadot/sources/__init__.py @@ -4,6 +4,7 @@ from .cloud_for_customers import CloudForCustomers from .duckdb import DuckDB +from .epicor import Epicor from .exchange_rates import ExchangeRates from .genesys import Genesys from .hubspot import Hubspot @@ -17,6 +18,7 @@ __all__ = [ "CloudForCustomers", + "Epicor", "ExchangeRates", "Genesys", "Outlook", diff --git a/src/viadot/sources/epicor.py b/src/viadot/sources/epicor.py new file mode 100644 index 000000000..b17743196 --- /dev/null +++ b/src/viadot/sources/epicor.py @@ -0,0 +1,488 @@ +"""Source for connecting to Epicor Prelude API.""" + +from typing import Any + +import defusedxml.ElementTree as ET # noqa: N817 +import pandas as pd +from pydantic import BaseModel +import requests + +from viadot.config import get_source_credentials +from viadot.exceptions import DataRangeError, ValidationError +from viadot.sources.base import Source +from viadot.utils import handle_api_response + + +"""The official documentation does not specify the list of required +fields so they were set as optional in BaseModel classes. + +Each Epicor Prelude view requires different XML parser. +""" + + +class TrackingNumbers(BaseModel): + TrackingNumber: str | None + + +class ShipToAddress(BaseModel): + ShipToNumber: str | None + Attention: str | None + AddressLine1: str | None + AddressLine2: str | None + AddressLine3: str | None + City: str | None + State: str | None + Zip: str | None + Country: str | None + EmailAddress: str | None + PhoneNumber: str | None + FaxNumber: str | None + + +class InvoiceTotals(BaseModel): + Merchandise: str | None + InboundFreight: str | None + OutboundFreight: str | None + Handling: str | None + Delivery: str | None + Pickup: str | None + Restocking: str | None + MinimumCharge: str | None + DiscountAllowance: str | None + SalesTax: str | None + TotalInvoice: str | None + + +class HeaderInformation(BaseModel): + CompanyNumber: str | None + OrderNumber: str | None + InvoiceNumber: str | None + CustomerNumber: str | None + CustomerDescription: str | None + CustomerPurchaseOrderNumber: str | None + Contact: str | None + SellingWarehouse: str | None + ShippingWarehouse: str | None + ShippingMethod: str | None + PaymentTerms: str | None + PaymentTermsDescription: str | None + FreightTerms: str | None + FreightTermsDescription: str | None + SalesRepOne: str | None + SalesRepOneDescription: str | None + EntryDate: str | None + OrderDate: str | None + RequiredDate: str | None + ShippedDate: str | None + InvoiceDate: str | None + ShipToAddress: ShipToAddress | None + TrackingNumbers: TrackingNumbers | None + InvoiceTotals: InvoiceTotals | None + + +class LineItemDetail(BaseModel): + ProductNumber: str | None + ProductDescription1: str | None + ProductDescription2: str | None + CustomerProductNumber: str | None + LineItemNumber: str | None + QuantityOrdered: str | None + QuantityShipped: str | None + QuantityBackordered: str | None + Price: str | None + UnitOfMeasure: str | None + ExtendedPrice: str | None + QuantityShippedExtension: str | None + LineItemShipWarehouse: str | None + RequiredDate: str | None + CopperWeight: str | None + + +class Customer(BaseModel): + CompanyNumber: str | None + CustomerNumber: str | None + Description: str | None + AddressOne: str | None + AddressTwo: str | None + AddressThree: str | None + City: str | None + State: str | None + Zip: str | None + Country: str | None + Contact: str | None + Phone: str | None + EmailAddress: str | None + ShipTos: ShipToAddress | None + + +class Order(BaseModel): + HeaderInformation: HeaderInformation | None + LineItemDetail: LineItemDetail | None + + +class BookingsInfo(BaseModel): + HeaderInformation: HeaderInformation | None + LineItemDetail: LineItemDetail | None + + +def parse_orders_xml(xml_data: str) -> pd.DataFrame: # noqa: C901, PLR0912 + """Function to parse xml containing Epicor Orders Data. + + Args: + xml_data (str, required): Response from Epicor API in form of xml + + Returns: + pd.DataFrame: DataFrame containing parsed orders data. + """ + final_df = pd.DataFrame() + ship_dict = {} + invoice_dict = {} + header_params_dict = {} + item_params_dict = {} + + root = ET.fromstring(xml_data.text) + + for order in root.findall("Order"): + for header in order.findall("HeaderInformation"): + for tracking_numbers in header.findall("TrackingNumbers"): + numbers = "" + for tracking_number in tracking_numbers.findall("TrackingNumber"): + numbers = numbers + "'" + tracking_number.text + "'" + result_numbers = TrackingNumbers(TrackingNumber=numbers) + + for shipto in header.findall("ShipToAddress"): + for ship_param in ShipToAddress.__dict__.get("__annotations__"): + try: + ship_value = shipto.find(ship_param).text + except AttributeError: + ship_value = None + ship_parameter = {ship_param: ship_value} + ship_dict.update(ship_parameter) + ship_address = ShipToAddress(**ship_dict) + + for invoice in header.findall("InvoiceTotals"): + for invoice_param in InvoiceTotals.__dict__.get("__annotations__"): + try: + invoice_value = invoice.find(invoice_param).text + except AttributeError: + invoice_value = None + invoice_parameter = {invoice_param: invoice_value} + invoice_dict.update(invoice_parameter) + invoice_total = InvoiceTotals(**invoice_dict) + + for header_param in HeaderInformation.__dict__.get("__annotations__"): + try: + header_value = header.find(header_param).text + except AttributeError: + header_value = None + if header_param == "TrackingNumbers": + header_parameter = {header_param: result_numbers} + elif header_param == "ShipToAddress": + header_parameter = {header_param: ship_address} + elif header_param == "InvoiceTotals": + header_parameter = {header_param: invoice_total} + else: + header_parameter = {header_param: header_value} + header_params_dict.update(header_parameter) + header_info = HeaderInformation(**header_params_dict) + for items in order.findall("LineItemDetails"): + for item in items.findall("LineItemDetail"): + for item_param in LineItemDetail.__dict__.get("__annotations__"): + try: + item_value = item.find(item_param).text + except AttributeError: + item_value = None + item_parameter = {item_param: item_value} + item_params_dict.update(item_parameter) + line_item = LineItemDetail(**item_params_dict) + row = Order(HeaderInformation=header_info, LineItemDetail=line_item) + my_dict = row.dict() + final_df = final_df.append( + pd.json_normalize(my_dict, max_level=2), ignore_index=True + ) + return final_df + + +def parse_bookings_xml(xml_data: str) -> pd.DataFrame: # noqa: C901, PLR0912 + """Function to parse xml containing Epicor Bookings Data. + + Args: + xml_data (str, required): Response from Epicor API in form of xml + Returns: + pd.DataFrame: DataFrame containing parsed data. + """ + final_df = pd.DataFrame() + ship_dict = {} + header_params_dict = {} + item_params_dict = {} + root = ET.fromstring(xml_data.text) + + for booking in root.findall("BookingsInfo"): + for header in booking.findall("HeaderInformation"): + for shipto in header.findall("ShipToAddress"): + for ship_param in ShipToAddress.__dict__.get("__annotations__"): + try: + ship_value = shipto.find(ship_param).text + except AttributeError: + ship_value = None + ship_parameter = {ship_param: ship_value} + ship_dict.update(ship_parameter) + ship_address = ShipToAddress(**ship_dict) + + for header_param in HeaderInformation.__dict__.get("__annotations__"): + try: + header_value = header.find(header_param).text + except AttributeError: + header_value = None + if header_param == "ShipToAddress": + header_parameter = {header_param: ship_address} + else: + header_parameter = {header_param: header_value} + header_params_dict.update(header_parameter) + header_info = HeaderInformation(**header_params_dict) + for items in booking.findall("LineItemDetails"): + for item in items.findall("LineItemDetail"): + for item_param in LineItemDetail.__dict__.get("__annotations__"): + try: + item_value = item.find(item_param).text + except AttributeError: + item_value = None + item_parameter = {item_param: item_value} + item_params_dict.update(item_parameter) + line_item = LineItemDetail(**item_params_dict) + row = BookingsInfo( + HeaderInformation=header_info, LineItemDetail=line_item + ) + my_dict = row.dict() + final_df = final_df.append( + pd.json_normalize(my_dict, max_level=2), ignore_index=True + ) + return final_df + + +def parse_open_orders_xml(xml_data: str) -> pd.DataFrame: # noqa: C901, PLR0912 + """Function to parse xml containing Epicor Open Orders Data. + + Args: + xml_data (str, required): Response from Epicor API in form of xml + Returns: + pd.DataFrame: DataFrame containing parsed data. + """ + final_df = pd.DataFrame() + ship_dict = {} + header_params_dict = {} + item_params_dict = {} + + root = ET.fromstring(xml_data.text) + + for order in root.findall("Order"): + for header in order.findall("HeaderInformation"): + for shipto in header.findall("ShipToAddress"): + for ship_param in ShipToAddress.__dict__.get("__annotations__"): + try: + ship_value = shipto.find(ship_param).text + except AttributeError: + ship_value = None + ship_parameter = {ship_param: ship_value} + ship_dict.update(ship_parameter) + ship_address = ShipToAddress(**ship_dict) + + for header_param in HeaderInformation.__dict__.get("__annotations__"): + try: + header_value = header.find(header_param).text + except AttributeError: + header_value = None + if header_param == "ShipToAddress": + header_parameter = {header_param: ship_address} + else: + header_parameter = {header_param: header_value} + header_params_dict.update(header_parameter) + header_info = HeaderInformation(**header_params_dict) + for items in order.findall("LineItemDetails"): + for item in items.findall("LineItemDetail"): + for item_param in LineItemDetail.__dict__.get("__annotations__"): + try: + item_value = item.find(item_param).text + except AttributeError: + item_value = None + item_parameter = {item_param: item_value} + item_params_dict.update(item_parameter) + line_item = LineItemDetail(**item_params_dict) + row = Order(HeaderInformation=header_info, LineItemDetail=line_item) + my_dict = row.dict() + final_df = final_df.append( + pd.json_normalize(my_dict, max_level=2), ignore_index=True + ) + return final_df + + +def parse_customer_xml(xml_data: str) -> pd.DataFrame: + """Function to parse xml containing Epicor Customers Data. + + Args: + xml_data (str, required): Response from Epicor API in form of xml + Returns: + pd.DataFrame: DataFrame containing parsed data. + """ + final_df = pd.DataFrame() + ship_dict = {} + customer_params_dict = {} + + root = ET.fromstring(xml_data.text) + + for customer in root.findall("Customer"): + for ship in customer.findall("ShipTos"): + for shipto in ship.findall("ShipToAddress"): + for ship_param in ShipToAddress.__dict__.get("__annotations__"): + try: + ship_value = shipto.find(ship_param).text + except AttributeError: + ship_value = None + ship_parameter = {ship_param: ship_value} + ship_dict.update(ship_parameter) + ship_address = ShipToAddress(**ship_dict) + + for cust_param in Customer.__dict__.get("__annotations__"): + try: + cust_value = customer.find(cust_param).text + except AttributeError: + cust_value = None + if cust_param == "ShipTos": + cust_parameter = {cust_param: ship_address} + else: + cust_parameter = {cust_param: cust_value} + customer_params_dict.update(cust_parameter) + cust_info = Customer(**customer_params_dict) + my_dict = cust_info.dict() + final_df = final_df.append( + pd.json_normalize(my_dict, max_level=2), ignore_index=True + ) + return final_df + + +class EpicorCredentials(BaseModel): + host: str + port: int = 443 + username: str + password: str + + +class Epicor(Source): + def __init__( + self, + base_url: str, + credentials: dict[str, Any] | None = None, + config_key: str | None = None, + validate_date_filter: bool = True, + start_date_field: str = "BegInvoiceDate", + end_date_field: str = "EndInvoiceDate", + *args, + **kwargs, + ): + """Class to connect to Epicor API and pasere XML output into a pandas DataFrame. + + Args: + base_url (str, required): Base url to Epicor. + filters_xml (str, required): Filters in form of XML. The date filter + is required. + credentials (dict[str, Any], optional): Credentials to connect with + Epicor API containing host, port, username and password.Defaults to None. + config_key (str, optional): Credential key to dictionary where details + are stored. + validate_date_filter (bool, optional): Whether or not validate xml + date filters. Defaults to True. + start_date_field (str, optional) The name of filters field containing + start date.Defaults to "BegInvoiceDate". + end_date_field (str, optional) The name of filters field containing + end date. Defaults to "EndInvoiceDate". + """ + raw_creds = credentials or get_source_credentials(config_key) or {} + validated_creds = EpicorCredentials(**raw_creds).dict( + by_alias=True + ) # validate the credentials + + self.credentials = validated_creds + self.config_key = config_key + self.base_url = base_url + self.validate_date_filter = validate_date_filter + self.start_date_field = start_date_field + self.end_date_field = end_date_field + + self.url = ( + "http://" + + validated_creds["host"] + + ":" + + str(validated_creds["port"]) + + base_url + ) + + super().__init__(*args, credentials=validated_creds, **kwargs) + + def generate_token(self) -> str: + """Function to generate API access token that is valid for 24 hours. + + Returns: + str: Generated token. + """ + url = ( + "http://" + + self.credentials["host"] + + ":" + + str(self.credentials["port"]) + + "/api/security/token/" + ) + + headers = { + "Content-Type": "application/json", + "username": self.credentials["username"], + "password": self.credentials["password"], + } + + response = handle_api_response(url=url, headers=headers, method="POST") + root = ET.fromstring(response.text) + return root.find("AccessToken").text + + def validate_filter(self, filters_xml: str) -> None: + "Function checking if user had specified date range filters." + root = ET.fromstring(filters_xml) + for child in root: + for subchild in child: + if ( + subchild.tag in (self.start_date_field, self.end_date_field) + ) and subchild.text is None: + msg = "Too much data. Please provide a date range filter." + raise DataRangeError(msg) + + def get_xml_response(self, filters_xml: str) -> requests.models.Response: + "Function for getting response from Epicor API." + if self.validate_date_filter is True: + self.validate_filter(filters_xml) + payload = filters_xml + headers = { + "Content-Type": "application/xml", + "Authorization": "Bearer " + self.generate_token(), + } + return handle_api_response( + url=self.url, headers=headers, data=payload, method="POST" + ) + + def to_df(self, filters_xml: str) -> pd.DataFrame: + """Function for creating pandas DataFrame from Epicor API response. + + Returns: + pd.DataFrame: Output DataFrame. + """ + data = self.get_xml_response(filters_xml) + if "ORDER.HISTORY.DETAIL.QUERY" in self.base_url: + df = parse_orders_xml(data) + elif "CUSTOMER.QUERY" in self.base_url: + df = parse_customer_xml(data) + elif "ORDER.DETAIL.PROD.QUERY" in self.base_url: + df = parse_open_orders_xml(data) + elif "BOOKINGS.DETAIL.QUERY" in self.base_url: + df = parse_bookings_xml(data) + else: + msg = f"Parser for selected viev {self.base_url} is not avaiable" + raise ValidationError(msg) + + return df diff --git a/tests/integration/test_epicor.py b/tests/integration/test_epicor.py new file mode 100644 index 000000000..b6f61cd19 --- /dev/null +++ b/tests/integration/test_epicor.py @@ -0,0 +1,120 @@ +import pandas as pd +import pytest +from viadot.config import get_source_credentials +from viadot.exceptions import DataRangeError +from viadot.sources import Epicor + + +FILTERS_NOK = """ + + + 001 + + 2022-05-16 + 3 + + """ + +FILTERS_ORDERS = """ + + + 001 + 2022-05-16 + 2022-05-16 + 3 + + """ +FILTERS_BOOKINGS = """ + + + + + + + + 2022-05-16 + 2022-05-16 + + + + + + """ +FILTERS_OPEN_ORDERS = """ + + + + + + + + + + + + + + + + + + + + """ +FILTERS_CUSTOMERS = """ + + + + + + + + + + + + + 1 + + + + + """ + + +@pytest.fixture(scope="session") +def epicor(): + return Epicor( + base_url=get_source_credentials("epicor").get("test_url"), + config_key="epicor", + validate_date_filter=False, + ) + + +def test_connection(epicor: Epicor): + assert epicor.get_xml_response(FILTERS_ORDERS).ok + + +def test_validate_filter(epicor: Epicor): + with pytest.raises(DataRangeError): + epicor.validate_filter(FILTERS_NOK) + + +def test_to_df_orders(epicor: Epicor): + df = epicor.to_df(FILTERS_ORDERS) + assert isinstance(df, pd.DataFrame) + + +def test_to_df_bookings(epicor: Epicor): + df = epicor.to_df(FILTERS_BOOKINGS) + assert isinstance(df, pd.DataFrame) + + +def test_to_df_open_orders(epicor: Epicor): + df = epicor.to_df(FILTERS_OPEN_ORDERS) + assert isinstance(df, pd.DataFrame) + + +def test_to_df_customers(epicor: Epicor): + df = epicor.to_df(FILTERS_CUSTOMERS) + assert isinstance(df, pd.DataFrame)