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)