drivelinepy 1.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Driveline Research and Development
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.1
2
+ Name: drivelinepy
3
+ Version: 1.5.1
4
+ Summary: A Python package for Driveline Baseball API interactions
5
+ Home-page: https://github.com/drivelineresearch/drivelinepy
6
+ Author: Garrett York
7
+ Author-email: garrett@drivelinebaseball.com
8
+ License: UNKNOWN
9
+ Platform: UNKNOWN
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.6.6
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+
17
+ # Drivelinepy
18
+
19
+ ## Installation
20
+
21
+ ```
22
+ pip install drivelinepy
23
+ ```
24
+
25
+ [Using Environment Variables](/docs/env-variables.md).
26
+
27
+ ## System Specific Docs
28
+ - [Slack API](/docs/slack-api.md)
29
+ - [TRAQ API](/docs/traq-api.md)
30
+ - [Virtuagym API](/docs/virtuagym-api.md)
31
+ - [Zoho APIs](/docs/zoho-overview.md)
32
+
33
+
34
+
@@ -0,0 +1,16 @@
1
+ # Drivelinepy
2
+
3
+ ## Installation
4
+
5
+ ```
6
+ pip install drivelinepy
7
+ ```
8
+
9
+ [Using Environment Variables](/docs/env-variables.md).
10
+
11
+ ## System Specific Docs
12
+ - [Slack API](/docs/slack-api.md)
13
+ - [TRAQ API](/docs/traq-api.md)
14
+ - [Virtuagym API](/docs/virtuagym-api.md)
15
+ - [Zoho APIs](/docs/zoho-overview.md)
16
+
@@ -0,0 +1,6 @@
1
+ from .base_api_wrapper import BaseAPIWrapper
2
+ from .zoho_billing_api import ZohoBillingAPI
3
+ from .zoho_crm_api import ZohoCrmAPI
4
+ from .virtuagym_api import VirtuagymAPI
5
+ from .traq_api import TRAQAPI
6
+ from .slack_api import SlackAPI
@@ -0,0 +1,184 @@
1
+ #=================================================================
2
+ # Author: Garrett York
3
+ # Date: 2023/11/29
4
+ # Description: Boiler plate code for API wrapper
5
+ #=================================================================
6
+
7
+ import requests
8
+ import logging
9
+ from datetime import datetime
10
+ import time
11
+ from urllib.parse import urljoin
12
+ import re
13
+
14
+ class BaseAPIWrapper():
15
+
16
+ #-----------------------------------------------------------------
17
+ # Constructor
18
+ #-----------------------------------------------------------------
19
+ def __init__(self, base_url, auth_url=None):
20
+ self.base_url = base_url
21
+ self.auth_url = auth_url
22
+ self.logger = logging.getLogger(__name__)
23
+
24
+ #-----------------------------------------------------------------
25
+ # Method - Construct URL
26
+ #-----------------------------------------------------------------
27
+
28
+ def _url(self, path, is_auth=False):
29
+ """Construct URL from base/auth url and path."""
30
+ if is_auth:
31
+ url = urljoin(self.auth_url, path)
32
+ else:
33
+ url = urljoin(self.base_url, path)
34
+ return url
35
+
36
+ #-----------------------------------------------------------------
37
+ # Method - Prepare Parameters
38
+ #-----------------------------------------------------------------
39
+
40
+ def _prepare_params(self, base_params, additional_params=None):
41
+ """
42
+ Prepares and merges base parameters with additional parameters.
43
+
44
+ :param base_params: A dictionary of base parameters common to all API requests.
45
+ :param additional_params: A dictionary of additional parameters specific to a particular API request.
46
+ :return: A merged dictionary of parameters with none values filtered out.
47
+ """
48
+ self.logger.info(f"Entering _prepare_params() with base parameters: {base_params}")
49
+
50
+ if base_params is None:
51
+ base_params = {}
52
+ params = base_params.copy()
53
+
54
+ if additional_params is not None:
55
+ params.update(additional_params) # Merges additional parameters
56
+
57
+ # Filtering out None values
58
+ filtered_params = {k: v for k, v in params.items() if v is not None}
59
+
60
+ self.logger.info("Exiting _prepare_params()")
61
+ return filtered_params
62
+
63
+
64
+ #-----------------------------------------------------------------
65
+ # Method - Send Request
66
+ #-----------------------------------------------------------------
67
+
68
+ def _send_request(self, method, path, params=None, data=None, headers=None, is_auth=False, files=None):
69
+ """Send a request to the API."""
70
+ url = self._url(path, is_auth=is_auth)
71
+
72
+ # Prepare the keyword arguments for the request method.
73
+ request_kwargs = {
74
+ 'params': params,
75
+ 'headers': headers,
76
+ 'data': data,
77
+ 'files': files
78
+ }
79
+
80
+ # Make sure not to send 'None' values
81
+ request_kwargs = {k: v for k, v in request_kwargs.items() if v is not None}
82
+
83
+ try:
84
+ # Make a single request with the conditional arguments.
85
+ response = requests.request(method, url, **request_kwargs)
86
+
87
+ response.raise_for_status()
88
+ return response.json()
89
+ except requests.exceptions.HTTPError as err:
90
+ self.logger.error(f'HTTP error occurred: {err}')
91
+ except requests.exceptions.RequestException as req_err:
92
+ self.logger.error(f'Request error: {req_err}')
93
+ except Exception as err:
94
+ self.logger.error(f'An unexpected error occurred: {err}')
95
+ return None
96
+
97
+ #-----------------------------------------------------------------
98
+ # Method - HTTP Functions
99
+ #-----------------------------------------------------------------
100
+
101
+ def get(self, path, params=None, headers=None):
102
+ """GET request."""
103
+ return self._send_request('GET', path, params=params, headers=headers)
104
+
105
+ def post(self, path, data=None, headers=None, is_auth=False, files=None):
106
+ """POST request."""
107
+ return self._send_request('POST', path, data=data, headers=headers, is_auth=is_auth, files=files)
108
+
109
+ def put(self, path, data, headers=None):
110
+ """PUT request."""
111
+ return self._send_request('PUT', path, data=data, headers=headers)
112
+
113
+ def options(self, path, headers=None):
114
+ """OPTIONS request."""
115
+ return self._send_request('OPTIONS', path, headers=headers)
116
+
117
+ def delete(self, path, headers=None):
118
+ """DELETE request."""
119
+ return self._send_request('DELETE', path, headers=headers)
120
+
121
+ #-----------------------------------------------------------------
122
+ # Method - Convert Date to Timestamp
123
+ #-----------------------------------------------------------------
124
+
125
+ def convert_yyyy_mm_dd_to_timestamp(self, date_str, is_end_date=False):
126
+ """
127
+ Convert a date string to a Unix timestamp.
128
+
129
+ :param date_str: Date string in 'YYYY-MM-DD' format.
130
+ :param is_end_date: Boolean indicating whether the date is an end date.
131
+ If True, it will set the time to the end of the day.
132
+ :return: Unix timestamp corresponding to the provided date.
133
+ """
134
+ self.logger.info(f'Entering convert_yyyy_mm_dd_to_timestamp() for date {date_str}')
135
+
136
+ if is_end_date:
137
+ date_time_str = date_str + " 23:59:59.999999"
138
+ else:
139
+ date_time_str = date_str + " 00:00:00.000000"
140
+
141
+ # Converting to datetime object
142
+ date_time_obj = datetime.strptime(date_time_str, '%Y-%m-%d %H:%M:%S.%f')
143
+ # Converting to Unix timestamp
144
+ timestamp = time.mktime(date_time_obj.timetuple())
145
+ self.logger.info("Exiting convert_yyyy_mm_dd_to_timestamp()")
146
+ return timestamp
147
+
148
+ #-----------------------------------------------------------------
149
+ # Method - Validate Date Format
150
+ #-----------------------------------------------------------------
151
+
152
+ def validate_date_yyyy_mm_dd(self, date_str):
153
+ """
154
+ Validate a date string in 'YYYY-MM-DD' format.
155
+
156
+ :param date_str: Date string in 'YYYY-MM-DD' format.
157
+ :return: Boolean indicating whether the date string is valid.
158
+ """
159
+ try:
160
+ datetime.strptime(date_str, '%Y-%m-%d')
161
+ self.logger.info(f"Date string is valid: {date_str}. Exiting validate_date_yyyy_mm_dd()")
162
+ return True
163
+ except ValueError:
164
+ self.logger.error(f"Incorrect data format for date string '{date_str}', should be YYYY-MM-DD. Exiting validate_date_yyyy_mm_dd()")
165
+ return False
166
+
167
+ #-----------------------------------------------------------------
168
+ # Method - Validate Email
169
+ #-----------------------------------------------------------------
170
+
171
+ def validate_email(self, email):
172
+ """
173
+ Validates that the provided string is a valid email format.
174
+
175
+ :param email: The email address to be validated.
176
+ :return: Boolean indicating whether the email is valid.
177
+ """
178
+ email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
179
+ if re.match(email_regex, email):
180
+ self.logger.info(f"Email address {email} is valid.")
181
+ return True
182
+ else:
183
+ self.logger.error(f"Invalid email address: {email}.")
184
+ return False
@@ -0,0 +1,176 @@
1
+ # =================================================================
2
+ # Author: Diego Plata
3
+ # Date: 2023/12/19
4
+ # Description: Class for Brightpearl API
5
+ # =================================================================
6
+
7
+ from base_api_wrapper import BaseAPIWrapper
8
+ from datetime import datetime
9
+ from brightpearl_utils.helper_functions import *
10
+
11
+ class BrightpearlApi(BaseAPIWrapper):
12
+
13
+ # -----------------------------------------------------------------
14
+ # Method - Constructor
15
+ # -----------------------------------------------------------------
16
+
17
+ def __init__(self, client_id, client_secret, refresh_token, api_iteration_limit=50,
18
+ auth_url="https://oauth.brightpearl.com", base_url=""):
19
+ super().__init__(base_url)
20
+ self.auth_url = auth_url
21
+ self.client_id = client_id
22
+ self.client_secret = client_secret
23
+ self.refresh_token = refresh_token
24
+ self.api_iteration_limit = api_iteration_limit # Number of times to call an API endpoint in a single function
25
+ self.time_of_last_refresh = None # Set in _refresh_access_token()
26
+ self.access_token = None # Set in _refresh_access_token()
27
+ self._refresh_access_token()
28
+
29
+ # -----------------------------------------------------------------
30
+ # Method - Refresh Access Token
31
+ # -----------------------------------------------------------------
32
+
33
+ def _refresh_access_token(self):
34
+
35
+ """
36
+ Refreshes the OAuth access token using the refresh token.
37
+
38
+ :return: True if the access token was successfully refreshed, False otherwise.
39
+ """
40
+
41
+ self.logger.info("Entering _refresh_access_token()")
42
+ self.time_of_last_refresh = datetime.now()
43
+
44
+ path = "/token/driveline"
45
+
46
+ data = {
47
+ 'refresh_token': self.refresh_token,
48
+ 'client_id': self.client_id,
49
+ 'client_secret': self.client_secret,
50
+ 'grant_type': 'refresh_token'
51
+ }
52
+ headers = {}
53
+
54
+ response = self.post(path=path, headers=headers, data=data, is_auth=True)
55
+
56
+ # valid if response is successful, if it was return true, if not return false
57
+ if response is None:
58
+ self.logger.error("Failed to refresh access token - response is None")
59
+ return False
60
+ else:
61
+ # if access token exists, return true
62
+ if "access_token" in response:
63
+ self.access_token = response["access_token"]
64
+ self.base_url = f"https://{response['api_domain']}"
65
+ self.logger.info("Access token refreshed and base url set to api domain")
66
+ return True
67
+ else:
68
+ self.logger.error("Failed to refresh access token - access token does not exist in response")
69
+ return False
70
+
71
+ # -----------------------------------------------------------------
72
+ # Method - Check Last Time Access Token Was Refreshed
73
+ # -----------------------------------------------------------------
74
+
75
+ def check_if_access_token_needs_refreshed(self):
76
+ """
77
+ Checks if the access token needs to be refreshed.
78
+ return: True if the access token needs to be refreshed, False otherwise.
79
+ """
80
+ self.logger.info("Entering check_last_refresh()")
81
+
82
+ time_since_last_refresh = datetime.now() - self.time_of_last_refresh
83
+
84
+ if time_since_last_refresh.seconds > 3600:
85
+ self.logger.info("Exiting check_last_refresh() - Token needs to be refreshed")
86
+ return True
87
+ else:
88
+ self.logger.info("Exiting check_last_refresh() - Token does not need to be refreshed")
89
+ return False
90
+
91
+ # -----------------------------------------------------------------
92
+ # Method - Get Headers
93
+ # -----------------------------------------------------------------
94
+
95
+ def _get_headers(self):
96
+ self.logger.info("Entering _get_headers()")
97
+ if self.check_if_access_token_needs_refreshed():
98
+ if not self._refresh_access_token():
99
+ return None
100
+
101
+ headers = {
102
+ 'Authorization': f"Bearer {self.access_token}",
103
+ 'Content-Type': 'application/json',
104
+ 'brightpearl-app-ref': self.client_id,
105
+ 'brightpearl-dev-ref': 'driveline1058'
106
+ }
107
+
108
+ self.logger.info(f'Headers have been set!')
109
+ self.logger.info("Exiting _get_headers()")
110
+ return headers
111
+
112
+ def get_all_sales_orders_from_date(self, since_date, page_size):
113
+ # TODO: add docstring documentation
114
+ self.logger.info("Entering get_all_sales_orders()")
115
+ endpoint = "/public-api/driveline/order-service/sales-order-search"
116
+
117
+ result_index = 1
118
+ today = pd.to_datetime('today').date()
119
+ params = {
120
+ "createdOn": f"{since_date}T00:00/{today}T00:00",
121
+ "pageSize": page_size,
122
+ "firstResult": result_index
123
+ }
124
+
125
+ sales_orders = self._fetch_sales_orders_by_date(self.base_url+endpoint, params)
126
+
127
+ return sales_orders
128
+
129
+ def get_all_sales_orders_by_ids(self, sales_orders):
130
+ # TODO: add docstring documentation
131
+ self.logger.info("Entering get_all_sales_orders_by_id()")
132
+ order_service_endpoint = "/public-api/driveline/order-service"
133
+ sales_order_endpoint = "/public-api/driveline/order-service/sales-order"
134
+ params = {
135
+
136
+ }
137
+ headers = self._get_headers()
138
+ orders_ids = construct_sales_orders_url(sales_orders['salesOrderId'].values, self.base_url)
139
+ uris_response = self.options(f"{self.base_url}{sales_order_endpoint}/{','.join(orders_ids)}", headers=headers)
140
+ urls = uris_response["response"]["getUris"]
141
+ sales_orders = self._fetch_sales_orders_by_ids(f"{self.base_url}{order_service_endpoint}", params, urls)
142
+
143
+ return sales_orders
144
+
145
+ def _fetch_sales_orders_by_date(self, endpoint, initial_params):
146
+ # TODO: add docstring documentation
147
+ self.logger.info(f'Entering _fetch_data() for endpoint {endpoint}')
148
+ all_data_list = []
149
+ params = initial_params
150
+ headers = self._get_headers()
151
+ more_pages_available = True
152
+ while more_pages_available:
153
+ response = self.get(endpoint, params=params, headers=headers)
154
+ more_pages_available = response["response"]["metaData"]["morePagesAvailable"]
155
+ data_count = response["response"]["metaData"]["resultsReturned"]
156
+ first_index_returned = response["response"]["metaData"]["firstResult"]
157
+ last_index_returned = response["response"]["metaData"]["lastResult"]
158
+ self.logger.info(f"Retrieved {data_count} results from index {first_index_returned} to index {last_index_returned}")
159
+ all_data_list.append(sales_orders_list_to_dataframe(response["response"]["results"], response["response"]["metaData"]["columns"]))
160
+ result_index = response["response"]["metaData"]["lastResult"] + 1
161
+ params['firstResult'] = result_index
162
+
163
+ self.logger.info(f"Exiting _fetch_sales_orders_by_date()")
164
+ return pd.concat(all_data_list)
165
+
166
+ def _fetch_sales_orders_by_ids(self, endpoint, initial_params, urls):
167
+ # TODO: add docstring documentation
168
+ self.logger.info(f'Entering _fetch_data() for endpoint {endpoint}')
169
+ params = initial_params
170
+ headers = self._get_headers()
171
+ response = [self.get(endpoint+url, params=params, headers=headers) for url in urls]
172
+ individual_sales_orders_list = list()
173
+ for sale_order_dictionary in response:
174
+ individual_sales_orders_list.append(individual_sales_orders_to_dataframe(sale_order_dictionary["response"]))
175
+ self.logger.info(f"Exiting _fetch_sales_orders_by_ids()")
176
+ return pd.concat(individual_sales_orders_list)
@@ -0,0 +1,27 @@
1
+ import pandas as pd
2
+
3
+
4
+ def sales_orders_list_to_dataframe(results, columns):
5
+ header_columns = [column["name"] for column in columns]
6
+ return pd.DataFrame(results, columns=header_columns)
7
+
8
+ def construct_sales_orders_url(ids, base_url):
9
+ string_ids = [str(id) for id in ids]
10
+ return string_ids
11
+
12
+ def individual_sales_orders_to_dataframe(orders):
13
+ return pd.json_normalize(orders, sep='_')
14
+
15
+
16
+ def append_order_and_individual_sales_entry(orders):
17
+ row_frames = list()
18
+ for _, row in orders.iterrows():
19
+ row_frame = pd.json_normalize(row["rows"])
20
+ # can join later
21
+ row_frame["salesOrderId"] = row["id"]
22
+ row_frame = row_frame.add_prefix("individual_sale_entry_")
23
+ row_frames.append(row_frame)
24
+ row_frames = pd.concat(row_frames)
25
+ megaframe = orders.merge(row_frames, how='right',left_on=["id"], right_on=["individual_sale_entry_salesOrderId"], suffixes=("","sale_entry"))
26
+ megaframe = megaframe.drop("rows",axis=1)
27
+ return megaframe
@@ -0,0 +1,99 @@
1
+ #================================================================================
2
+ # Author: Garrett York
3
+ # Date: 2024/02/01
4
+ # Description: Class for Slack API
5
+ #================================================================================
6
+
7
+ from .base_api_wrapper import BaseAPIWrapper
8
+ import os
9
+ import mimetypes
10
+
11
+ class SlackAPI(BaseAPIWrapper):
12
+
13
+ #---------------------------------------------------------------------------
14
+ # Constructor
15
+ #---------------------------------------------------------------------------
16
+
17
+ def __init__(self, token, base_url="https://slack.com/api/"):
18
+ super().__init__(base_url)
19
+ self.token = token
20
+
21
+ #---------------------------------------------------------------------------
22
+ # Method - Post Message
23
+ #---------------------------------------------------------------------------
24
+
25
+ def post_message(self, channel, text):
26
+ """
27
+ Posts a message to a specified channel on Slack.
28
+
29
+ :param channel: The channel ID where the message will be posted.
30
+ :param text: The text of the message to post.
31
+ :return: The response from the Slack API.
32
+ """
33
+ self.logger.info("Entering post_message()")
34
+
35
+ endpoint = "chat.postMessage"
36
+ payload = {
37
+ 'channel': channel,
38
+ 'text': text
39
+ }
40
+ headers = {
41
+ 'Content-Type': 'application/x-www-form-urlencoded',
42
+ 'Authorization': f'Bearer {self.token}'
43
+ }
44
+
45
+ response = self.post(endpoint, data=payload, headers=headers)
46
+
47
+ self.logger.info("Exiting post_message()")
48
+ return response
49
+
50
+ #---------------------------------------------------------------------------
51
+ # Method - Post File + Message (optional)
52
+ #---------------------------------------------------------------------------
53
+
54
+ def upload_file(self, channel, file_absolute_path, text=None):
55
+
56
+ """
57
+ Uploads a file to a specified channel on Slack.
58
+
59
+ This method attempts to upload a file to the specified Slack channel. It first checks if the file exists at the given path and then determines its MIME type for proper uploading. The file is then uploaded with an optional initial comment.
60
+
61
+ :param channel: str
62
+ The channel ID where the file will be uploaded.
63
+ :param file_absolute_path: str
64
+ The absolute path to the file to upload.
65
+ :param text: str, optional
66
+ An initial comment to add when uploading the file. Defaults to None. The API documentation refers to this parameter is initial_comment
67
+ but it is referred to as text in this method to match the parameter name in the post_message method.
68
+
69
+ :return: dict
70
+ A dictionary response from the Slack API indicating the success or failure of the file upload.
71
+ """
72
+ self.logger.info("Entering upload_file()")
73
+
74
+ if not os.path.exists(file_absolute_path):
75
+ self.logger.error(f"File not found: {file_absolute_path}")
76
+ return {"ok": False, "error": "file_not_found"}
77
+
78
+ endpoint = "files.upload"
79
+
80
+ # Attempt to determine the MIME type of the file based on its extension.
81
+ # The `guess_type` function returns a tuple (MIME type, encoding),
82
+ # where we are only interested in the MIME type. The underscore (_)
83
+ # is used to ignore the encoding part of the returned tuple.
84
+
85
+ mime_type, _ = mimetypes.guess_type(file_absolute_path)
86
+ mime_type = mime_type or 'application/octet-stream'
87
+
88
+ try:
89
+ with open(file_absolute_path, 'rb') as file_content:
90
+ files = [('file', (os.path.basename(file_absolute_path), file_content, mime_type))]
91
+ payload = {'channels': channel, 'initial_comment': text}
92
+ headers = {'Authorization': f'Bearer {self.token}'}
93
+ response = self.post(endpoint, headers=headers, data=payload, files=files)
94
+ except Exception as e:
95
+ self.logger.error(f"Error uploading file: {e}")
96
+ return {"ok": False, "error": str(e)}
97
+
98
+ self.logger.info("Exiting upload_file()")
99
+ return response