brynq-sdk-trimergo 1.0.0__py3-none-any.whl
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.
- brynq_sdk_trimergo/__init__.py +211 -0
- brynq_sdk_trimergo/pageinfo_wrapper/__init__.py +0 -0
- brynq_sdk_trimergo/pageinfo_wrapper/pageinfo.py +160 -0
- brynq_sdk_trimergo/schemas/__init__.py +0 -0
- brynq_sdk_trimergo/schemas/timesheet.py +74 -0
- brynq_sdk_trimergo/timesheet.py +350 -0
- brynq_sdk_trimergo-1.0.0.dist-info/METADATA +17 -0
- brynq_sdk_trimergo-1.0.0.dist-info/RECORD +10 -0
- brynq_sdk_trimergo-1.0.0.dist-info/WHEEL +5 -0
- brynq_sdk_trimergo-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Trimergo API client for interacting with the Trimergo HR system.
|
|
2
|
+
|
|
3
|
+
This module provides a client for making authenticated requests to the Trimergo API,
|
|
4
|
+
handling authentication, retries, and session management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Standard library imports
|
|
8
|
+
import base64
|
|
9
|
+
from typing import Optional, Tuple, Any
|
|
10
|
+
|
|
11
|
+
# Third-party imports
|
|
12
|
+
import requests
|
|
13
|
+
from requests.adapters import HTTPAdapter
|
|
14
|
+
from urllib3.util.retry import Retry
|
|
15
|
+
|
|
16
|
+
# Local imports
|
|
17
|
+
from brynq_sdk_brynq import BrynQ
|
|
18
|
+
from .timesheet import Timesheet
|
|
19
|
+
|
|
20
|
+
class Trimergo(BrynQ):
|
|
21
|
+
"""Client for interacting with the Trimergo HR system API.
|
|
22
|
+
|
|
23
|
+
Handles authentication, session management, and provides methods for
|
|
24
|
+
accessing various Trimergo API services like Timesheet.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
trimergo_host (str): The base URL for the Trimergo API.
|
|
28
|
+
trimergo_session (requests.Session): The authenticated session for API requests.
|
|
29
|
+
openapi_json (dict): The OpenAPI specification for the Trimergo API.
|
|
30
|
+
timesheet (Timesheet): An instance of the Timesheet service client.
|
|
31
|
+
OPENAPI_VERSION (str): Expected OpenAPI version.
|
|
32
|
+
TRIMERGO_T2_WEBSERVICES_VERSION (str): Expected Trimergo T2 WebServices version.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
OPENAPI_VERSION: str = "3.0.1"
|
|
36
|
+
TRIMERGO_T2_WEBSERVICES_VERSION: str = "1.1.0"
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
interface_id: str,
|
|
41
|
+
test_environment: bool,
|
|
42
|
+
system_type: str
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initializes the Trimergo API client.
|
|
45
|
+
|
|
46
|
+
Retrieves credentials, initializes an authenticated session, and
|
|
47
|
+
sets up service-specific clients.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
interface_id (str): ID of the interface to get credentials for.
|
|
51
|
+
test_environment (bool): True if using the test environment, False otherwise.
|
|
52
|
+
system_type (str): Specifies 'source' or 'target' system.
|
|
53
|
+
"""
|
|
54
|
+
super().__init__()
|
|
55
|
+
# Retrieve trimergo's host (base url) and credentials
|
|
56
|
+
self.trimergo_host: str
|
|
57
|
+
trimergo_username: str
|
|
58
|
+
trimergo_password: str
|
|
59
|
+
self.trimergo_host, trimergo_username, trimergo_password = (
|
|
60
|
+
self._get_trimergo_credentials(
|
|
61
|
+
interface_id, test_environment, system_type
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
# Init trimergo session and retrieve openapi json
|
|
65
|
+
self.trimergo_session: requests.Session
|
|
66
|
+
self.openapi_json: dict
|
|
67
|
+
self.trimergo_session, self.openapi_json = self._init_trimergo_session(
|
|
68
|
+
trimergo_username, trimergo_password
|
|
69
|
+
)
|
|
70
|
+
# Init service classes
|
|
71
|
+
self.timesheet: Timesheet = Timesheet(self)
|
|
72
|
+
|
|
73
|
+
def _get_trimergo_credentials(
|
|
74
|
+
self,
|
|
75
|
+
interface_id: str = "1",
|
|
76
|
+
test_environment: bool = False,
|
|
77
|
+
system_type: Optional[str] = None,
|
|
78
|
+
) -> Tuple[str, str, str]:
|
|
79
|
+
"""Retrieves Trimergo API credentials from BrynQ's interface system.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
interface_id (str): ID of the interface.
|
|
83
|
+
test_environment (bool): True if using the test environment.
|
|
84
|
+
system_type (Optional[str]): 'source' or 'target'.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Tuple[str, str, str]: host, username, password.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: If any required credentials (host, username, password) are missing.
|
|
91
|
+
Exception: If credential retrieval fails for other reasons.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
creds = self.interfaces.credentials.get(
|
|
95
|
+
system="trimergo",
|
|
96
|
+
system_type=system_type,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if not creds or not creds.get("data"):
|
|
100
|
+
raise Exception(
|
|
101
|
+
f"Failed to retrieve credentials for interface_id {interface_id}. "
|
|
102
|
+
f"Response: {creds}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
data = creds["data"]
|
|
106
|
+
host = data.get("host")
|
|
107
|
+
username = data.get("username")
|
|
108
|
+
password = data.get("password")
|
|
109
|
+
|
|
110
|
+
missing_fields = []
|
|
111
|
+
if not host or not host.strip():
|
|
112
|
+
missing_fields.append("host")
|
|
113
|
+
if not username or not username.strip():
|
|
114
|
+
missing_fields.append("username")
|
|
115
|
+
if not password or not password.strip():
|
|
116
|
+
missing_fields.append("password")
|
|
117
|
+
|
|
118
|
+
if missing_fields:
|
|
119
|
+
error_message = f"Missing Trimergo credentials: {', '.join(missing_fields)}."
|
|
120
|
+
raise ValueError(error_message)
|
|
121
|
+
|
|
122
|
+
return str(host), str(username), str(password)
|
|
123
|
+
except ValueError:
|
|
124
|
+
raise
|
|
125
|
+
except Exception as e:
|
|
126
|
+
error_message = (
|
|
127
|
+
f"An unexpected error occurred while retrieving Trimergo credentials "
|
|
128
|
+
f"for interface_id {interface_id}: {e}"
|
|
129
|
+
)
|
|
130
|
+
raise Exception(error_message) from e
|
|
131
|
+
|
|
132
|
+
def _init_trimergo_session(
|
|
133
|
+
self, trimergo_username: str, trimergo_password: str
|
|
134
|
+
) -> Tuple[requests.Session, dict]:
|
|
135
|
+
"""Initializes and returns an authenticated Trimergo API session and OpenAPI spec.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
trimergo_username (str): Username for Trimergo API.
|
|
139
|
+
trimergo_password (str): Password for Trimergo API.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tuple[requests.Session, dict]: Authenticated session and OpenAPI JSON.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
Exception: If session initialization or version check fails.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
retry_strategy = Retry(
|
|
149
|
+
total=3,
|
|
150
|
+
backoff_factor=1,
|
|
151
|
+
status_forcelist=[500, 502, 503, 504, 400, 401, 403, 404],
|
|
152
|
+
allowed_methods=["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE", "POST"],
|
|
153
|
+
)
|
|
154
|
+
trimergo_session = requests.Session()
|
|
155
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
156
|
+
trimergo_session.mount("http://", adapter)
|
|
157
|
+
trimergo_session.mount("https://", adapter)
|
|
158
|
+
auth_string = f"{trimergo_username}:{trimergo_password}"
|
|
159
|
+
auth_b64 = self._encode_string(auth_string)
|
|
160
|
+
trimergo_session.headers.update({
|
|
161
|
+
"Authorization": f"Basic {auth_b64}",
|
|
162
|
+
"Content-Type": "application/json",
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
openapi_url = f"{self.trimergo_host}/tri-server-gateway-ws/api/rest/openapi.json"
|
|
166
|
+
response = trimergo_session.get(openapi_url)
|
|
167
|
+
response.raise_for_status()
|
|
168
|
+
|
|
169
|
+
openapi_json: dict = response.json()
|
|
170
|
+
|
|
171
|
+
api_openapi_version = openapi_json.get("openapi")
|
|
172
|
+
api_info_version = openapi_json.get("info", {}).get("version")
|
|
173
|
+
|
|
174
|
+
if api_openapi_version != self.OPENAPI_VERSION or \
|
|
175
|
+
api_info_version != self.TRIMERGO_T2_WEBSERVICES_VERSION:
|
|
176
|
+
warning_msg = (
|
|
177
|
+
f"Version mismatch detected for Trimergo API. "
|
|
178
|
+
f"Expected OpenAPI: {self.OPENAPI_VERSION} (Got: {api_openapi_version}), "
|
|
179
|
+
f"Expected T2 WebServices: {self.TRIMERGO_T2_WEBSERVICES_VERSION} "
|
|
180
|
+
f"(Got: {api_info_version}). API might not be compatible."
|
|
181
|
+
)
|
|
182
|
+
print(f"WARNING: {warning_msg}")
|
|
183
|
+
|
|
184
|
+
return trimergo_session, openapi_json
|
|
185
|
+
except requests.exceptions.HTTPError as http_err:
|
|
186
|
+
err_msg = (
|
|
187
|
+
f"HTTP error during Trimergo session initialization for {openapi_url}: "
|
|
188
|
+
f"{http_err.response.status_code} - {http_err.response.text}"
|
|
189
|
+
)
|
|
190
|
+
raise Exception(err_msg) from http_err
|
|
191
|
+
except requests.exceptions.RequestException as req_err:
|
|
192
|
+
err_msg = f"Request error initializing Trimergo session for {openapi_url}: {req_err}"
|
|
193
|
+
raise Exception(err_msg) from req_err
|
|
194
|
+
except Exception as e:
|
|
195
|
+
err_msg = (
|
|
196
|
+
f"Unexpected error during Trimergo session initialization for {openapi_url}: {e}"
|
|
197
|
+
)
|
|
198
|
+
raise Exception(err_msg) from e
|
|
199
|
+
|
|
200
|
+
def _encode_string(self, string_to_encode: str) -> str:
|
|
201
|
+
"""Encodes a string to Base64 for Trimergo API authentication headers.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
string_to_encode (str): The string to be Base64 encoded.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
str: The Base64 encoded string.
|
|
208
|
+
"""
|
|
209
|
+
string_bytes = string_to_encode.encode('utf-8')
|
|
210
|
+
string_b64_bytes = base64.b64encode(string_bytes)
|
|
211
|
+
return string_b64_bytes.decode('utf-8')
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Defines PageInfo, Criterion, and Sorter classes for Trimergo API interactions. It wraps/mirrors pageinof.js, which handles query parameters for the trimergo api.
|
|
2
|
+
|
|
3
|
+
These classes are used to construct and encode query parameters for filtering,
|
|
4
|
+
sorting, and paginating Trimergo API responses, particularly for services
|
|
5
|
+
like Timesheet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
# Helpers – JS‑identical Base64‑URL codec
|
|
13
|
+
_B64_PLUS = b"+"
|
|
14
|
+
_B64_SLASH = b"/"
|
|
15
|
+
_B64_DASH = b"-"
|
|
16
|
+
_B64_UNDERSCORE = b"_"
|
|
17
|
+
_B64_PAD = b"="
|
|
18
|
+
|
|
19
|
+
def _b64url_encode(raw: str) -> str:
|
|
20
|
+
"""Encodes a string to a URL-safe Base64 string."""
|
|
21
|
+
out = base64.b64encode(raw.encode())
|
|
22
|
+
out = out.replace(_B64_PLUS, _B64_DASH).replace(_B64_SLASH, _B64_UNDERSCORE)
|
|
23
|
+
return out.rstrip(_B64_PAD).decode()
|
|
24
|
+
|
|
25
|
+
def _b64url_decode(data: str) -> str:
|
|
26
|
+
"""Decodes a URL-safe Base64 string."""
|
|
27
|
+
fixed = data.replace("-", "+").replace("_", "/")
|
|
28
|
+
fixed += "=" * (-len(fixed) % 4)
|
|
29
|
+
return base64.b64decode(fixed).decode()
|
|
30
|
+
|
|
31
|
+
class Criterion:
|
|
32
|
+
"""Represents a filter criterion for Trimergo API queries."""
|
|
33
|
+
__slots__ = ("f", "o", "v", "q")
|
|
34
|
+
|
|
35
|
+
def __init__(self,
|
|
36
|
+
field: str,
|
|
37
|
+
operator: str,
|
|
38
|
+
value: Any,
|
|
39
|
+
sub: "Optional[Criterion]" = None):
|
|
40
|
+
"""Initializes a Criterion.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
field (str): The field to filter on.
|
|
44
|
+
operator (str): The operator to use (e.g., '=', '>', 'LIKE').
|
|
45
|
+
value (Any): The value to compare against.
|
|
46
|
+
sub (Optional[Criterion]): A sub-criterion for nested queries.
|
|
47
|
+
"""
|
|
48
|
+
self.f: str = field
|
|
49
|
+
self.o: str = operator
|
|
50
|
+
self.v: Any = value
|
|
51
|
+
self.q: List[Dict[str, Any]] = []
|
|
52
|
+
if sub is not None:
|
|
53
|
+
self.q.append(sub.to_dict())
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
56
|
+
"""Converts the criterion to its dictionary representation."""
|
|
57
|
+
return {"f": self.f,
|
|
58
|
+
"o": self.o,
|
|
59
|
+
"v": self.v,
|
|
60
|
+
"q": self.q}
|
|
61
|
+
|
|
62
|
+
class Sorter:
|
|
63
|
+
"""Represents a sort order for Trimergo API queries."""
|
|
64
|
+
ASC = "asc"
|
|
65
|
+
DESC = "desc"
|
|
66
|
+
__slots__ = ("f", "t")
|
|
67
|
+
|
|
68
|
+
def __init__(self, field: str,
|
|
69
|
+
direction: str):
|
|
70
|
+
"""Initializes a Sorter.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
field (str): The field to sort by.
|
|
74
|
+
direction (str): The sort direction ('asc' or 'desc').
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If direction is not 'asc' or 'desc'.
|
|
78
|
+
"""
|
|
79
|
+
if (d := direction.lower()) not in (self.ASC, self.DESC):
|
|
80
|
+
raise ValueError("Sorter direction must be 'asc' or 'desc'.")
|
|
81
|
+
self.f: str = field
|
|
82
|
+
self.t: str = d
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
85
|
+
"""Converts the sorter to its dictionary representation."""
|
|
86
|
+
return {"f": self.f, "t": self.t}
|
|
87
|
+
|
|
88
|
+
class PageInfo:
|
|
89
|
+
"""Represents pagination, filtering, and sorting parameters for Trimergo API queries.
|
|
90
|
+
|
|
91
|
+
This class provides methods to add criteria and sorters, and to encode these
|
|
92
|
+
parameters into the format expected by the Trimergo API.
|
|
93
|
+
"""
|
|
94
|
+
def __init__(self) -> None:
|
|
95
|
+
"""Initializes a PageInfo object with default pagination settings."""
|
|
96
|
+
self.pageNo: int = 1
|
|
97
|
+
self.rpp: int = 1000 # Records Per Page
|
|
98
|
+
self.criteria: List[Dict[str, Any]] = []
|
|
99
|
+
self.haystack: Dict[str, Any] = {"fields": [], "needle": ""}
|
|
100
|
+
self.sorters: List[Dict[str, Any]] = []
|
|
101
|
+
|
|
102
|
+
def addCriterion(self, c: Criterion) -> None:
|
|
103
|
+
"""Adds a criterion to the PageInfo.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
c (Criterion): The criterion to add.
|
|
107
|
+
"""
|
|
108
|
+
self.criteria.append({"q": c.to_dict()})
|
|
109
|
+
|
|
110
|
+
def wrapCriterion(self, wrapper: Criterion) -> None:
|
|
111
|
+
"""Wraps existing criteria with a new (outer) criterion, typically for AND logic.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
wrapper (Criterion): The criterion to wrap existing criteria with.
|
|
115
|
+
"""
|
|
116
|
+
for item in self.criteria:
|
|
117
|
+
wrapper.q.append(item["q"])
|
|
118
|
+
self.criteria = [{"q": wrapper.to_dict()}]
|
|
119
|
+
|
|
120
|
+
def addSorter(self, s: Sorter) -> None:
|
|
121
|
+
"""Adds a sorter to the PageInfo.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
s (Sorter): The sorter to add.
|
|
125
|
+
"""
|
|
126
|
+
self.sorters.append({"s": s.to_dict()})
|
|
127
|
+
|
|
128
|
+
def _criteria_json(self) -> str:
|
|
129
|
+
"""Internal helper to get criteria as a JSON string."""
|
|
130
|
+
# Trimergo's API expects empty sub-queries 'q':[] to be omitted
|
|
131
|
+
return json.dumps(self.criteria).replace(',"q":[]', "")
|
|
132
|
+
|
|
133
|
+
def getQ(self) -> str:
|
|
134
|
+
"""Gets the Base64-URL encoded criteria string ('q' parameter)."""
|
|
135
|
+
return _b64url_encode(self._criteria_json()) if self.criteria else ""
|
|
136
|
+
|
|
137
|
+
def getH(self) -> str:
|
|
138
|
+
"""Gets the Base64-URL encoded haystack search string ('h' parameter)."""
|
|
139
|
+
if not (self.haystack["fields"] and self.haystack["needle"]):
|
|
140
|
+
return ""
|
|
141
|
+
# Assuming haystack structure is correct for direct dump
|
|
142
|
+
return _b64url_encode(json.dumps({"h": self.haystack}))
|
|
143
|
+
|
|
144
|
+
def getO(self) -> str:
|
|
145
|
+
"""Gets the Base64-URL encoded sorters string ('o' parameter)."""
|
|
146
|
+
return _b64url_encode(json.dumps(self.sorters)) if self.sorters else ""
|
|
147
|
+
|
|
148
|
+
def getProperties(self) -> Dict[str, str]:
|
|
149
|
+
"""Constructs a dictionary of all page info properties for API query parameters."""
|
|
150
|
+
props: Dict[str, str] = {
|
|
151
|
+
"pageNo": str(self.pageNo),
|
|
152
|
+
"rpp": str(self.rpp)
|
|
153
|
+
}
|
|
154
|
+
if q_param := self.getQ():
|
|
155
|
+
props["q"] = q_param
|
|
156
|
+
if h_param := self.getH():
|
|
157
|
+
props["h"] = h_param
|
|
158
|
+
if o_param := self.getO():
|
|
159
|
+
props["o"] = o_param
|
|
160
|
+
return props
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import pandera as pa
|
|
2
|
+
from pandera.typing import Series, String, Float, DateTime
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from brynq_sdk_functions import BrynQPanderaDataFrameModel
|
|
6
|
+
|
|
7
|
+
class TimeSheetSchema(BrynQPanderaDataFrameModel):
|
|
8
|
+
# core timesheet attributes
|
|
9
|
+
actual_status: Series[String] = pa.Field(coerce=True, description="Actual Status (Y/N)", alias="actualStatus")
|
|
10
|
+
amount: Series[Float] = pa.Field(coerce=True, nullable=False, description="Billed Amount", alias="amount")
|
|
11
|
+
amount_break: Series[pd.Int64Dtype] = pa.Field(coerce=True, nullable=True, description="Amount Break (minutes)", alias="amountBreak")
|
|
12
|
+
approval_date: Series[DateTime] = pa.Field(coerce=True, nullable=True, description="Approval Date", alias="approvalDate")
|
|
13
|
+
approval_state: Series[String] = pa.Field(coerce=True, nullable=False, description="Approval State Code", alias="approvalState")
|
|
14
|
+
budget_key: Series[String] = pa.Field(coerce=True, nullable=False, description="Budget Key", alias="budgetKey")
|
|
15
|
+
etc: Series[Float] = pa.Field(coerce=True, nullable=True, description="ETC Value", alias="etc")
|
|
16
|
+
extra: Series[bool] = pa.Field(coerce=True, nullable=False, description="Has Extra Flag", alias="extra")
|
|
17
|
+
extra_time: Series[String] = pa.Field(coerce=True, nullable=True, description="Extra Time Code", alias="extraTime")
|
|
18
|
+
percentage_done: Series[Float] = pa.Field(coerce=True, nullable=True, description="% Complete", alias="percentageDone")
|
|
19
|
+
planning_key: Series[String] = pa.Field(coerce=True, nullable=False, description="Planning Key", alias="planningKey")
|
|
20
|
+
resource_key: Series[String] = pa.Field(coerce=True, nullable=False, description="Resource Key", alias="resourceKey")
|
|
21
|
+
show_on_report_ready: Optional[Series[pd.BooleanDtype]] = pa.Field(coerce=True, nullable=True, description="Visible On Report", alias="showOnReportReady")
|
|
22
|
+
str_extra: Series[String] = pa.Field(coerce=True, nullable=True, description="Extra Time (string)", alias="strExtra")
|
|
23
|
+
tdate: Series[DateTime] = pa.Field(coerce=True, nullable=False, description="Timesheet Date", alias="tdate")
|
|
24
|
+
time_from: Series[pd.Int64Dtype] = pa.Field(coerce=True, nullable=False, description="Start Minute of Day", alias="timeFrom")
|
|
25
|
+
time_to: Series[pd.Int64Dtype] = pa.Field(coerce=True, nullable=False, description="End Minute of Day", alias="timeTo")
|
|
26
|
+
timesheet_key: Series[String] = pa.Field(coerce=True, nullable=False, description="Timesheet Entry Key", alias="timesheetKey")
|
|
27
|
+
worktype_key: Series[String] = pa.Field(coerce=True, nullable=False, description="Work Type Key", alias="worktypeKey")
|
|
28
|
+
|
|
29
|
+
# project sub-object (flattened)
|
|
30
|
+
project_budget_key: Series[String] = pa.Field(coerce=True, nullable=False, description="Project Budget Key", alias="project_budgetKey")
|
|
31
|
+
project_description: Series[String] = pa.Field(coerce=True, nullable=False, description="Project Description", alias="project_description")
|
|
32
|
+
project_is_parent: Series[bool] = pa.Field(coerce=True, nullable=False, description="Project Is Parent", alias="project_isParent")
|
|
33
|
+
project_planning_exchange_rate: Series[Float] = pa.Field(coerce=True, nullable=False, description="Project Exchange Rate", alias="project_planningExchangeRate")
|
|
34
|
+
project_planning_key: Series[String] = pa.Field(coerce=True, nullable=False, description="Project Planning Key", alias="project_planningKey")
|
|
35
|
+
project_project_key: Series[String] = pa.Field(coerce=True, nullable=False, description="Project Key", alias="project_projectKey")
|
|
36
|
+
project_project_number: Series[String] = pa.Field(coerce=True, nullable=True, description="Project Number", alias="project_projectNumber")
|
|
37
|
+
project_project_type: Series[String] = pa.Field(coerce=True, nullable=False, description="Project Type Code", alias="project_projectType")
|
|
38
|
+
project_schedule_duration: Series[pd.Int64Dtype] = pa.Field(coerce=True, nullable=False, description="Scheduled Duration (min)", alias="project_scheduleDuration")
|
|
39
|
+
project_schedule_finish: Series[String] = pa.Field(coerce=True, nullable=True, description="Scheduled Finish", alias="project_scheduleFinish")
|
|
40
|
+
project_schedule_start: Series[String] = pa.Field(coerce=True, nullable=True, description="Scheduled Start", alias="project_scheduleStart")
|
|
41
|
+
project_status: Series[String] = pa.Field(coerce=True, nullable=False, description="Project Status Code", alias="project_status")
|
|
42
|
+
project_type_name: Series[String] = pa.Field(coerce=True, nullable=False, description="Type Name", alias="project_typeName")
|
|
43
|
+
project_user_type: Series[String] = pa.Field(coerce=True, nullable=False, description="User Type Code", alias="project_userType")
|
|
44
|
+
|
|
45
|
+
# client sub-object (flattened)
|
|
46
|
+
client_active: Optional[Series[pd.BooleanDtype]] = pa.Field(coerce=True, nullable=True, description="Client Is Active", alias="client_active")
|
|
47
|
+
client_city_address: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Client City", alias="client_cityAddress")
|
|
48
|
+
client_country_code: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Client Country Code", alias="client_countryCodeAddress")
|
|
49
|
+
client_phone1: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Primary Phone", alias="client_phone1")
|
|
50
|
+
client_phone2: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Secondary Phone", alias="client_phone2")
|
|
51
|
+
client_relation_address: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Street Address", alias="client_relationAddress")
|
|
52
|
+
client_relation_id: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Relation ID", alias="client_relationId")
|
|
53
|
+
client_relation_name: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Relation Name", alias="client_relationName")
|
|
54
|
+
client_relation_number: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Relation Number", alias="client_relationNumber")
|
|
55
|
+
client_relation_types: Optional[Series[pd.Int64Dtype]] = pa.Field(coerce=True, nullable=True, description="Relation Types", alias="client_relationTypes")
|
|
56
|
+
client_zip_address: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="ZIP Code", alias="client_zipAddress")
|
|
57
|
+
client_country_name: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Country Name", alias="client_countryNameAddress")
|
|
58
|
+
client_business_unit_key: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="Business Unit Key", alias="client_businessUnitKey")
|
|
59
|
+
|
|
60
|
+
# customFields.item[0].value
|
|
61
|
+
vv_project_uren_locatie: Optional[Series[String]] = pa.Field(coerce=True, nullable=True, description="VV Project Uren Locatie", alias="customFields_item_0_value")
|
|
62
|
+
|
|
63
|
+
# resource sub-object (flattened)
|
|
64
|
+
resource_active: Series[bool] = pa.Field(coerce=True, nullable=False, description="Resource Is Active", alias="resource_active")
|
|
65
|
+
resource_description: Series[String] = pa.Field(coerce=True, nullable=False, description="Resource Description", alias="resource_description")
|
|
66
|
+
resource_key: Series[String] = pa.Field(coerce=True, nullable=False, description="Resource Key", alias="resource_resourceKey")
|
|
67
|
+
resource_number: Series[String] = pa.Field(coerce=True, nullable=False, description="Resource Number", alias="resource_resourceNumber")
|
|
68
|
+
|
|
69
|
+
# workType sub-object
|
|
70
|
+
worktype_description: Series[String] = pa.Field(coerce=True, nullable=False, description="Work Type Description", alias="workType_description")
|
|
71
|
+
worktype_code: Series[String] = pa.Field(coerce=True, nullable=False, description="Work Type Code", alias="workType_workTypeCode")
|
|
72
|
+
|
|
73
|
+
class Config:
|
|
74
|
+
coerce = True
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""timesheet.py — Hybrid client for Trimergo timesheets.
|
|
2
|
+
Hybrid in the sense that it supports both pythonic keyword arguments or a PageInfo object for precise query construction.
|
|
3
|
+
Client because it is a client for the Trimergo timesheet API.
|
|
4
|
+
|
|
5
|
+
This module provides the Timesheet class, which allows interaction with the
|
|
6
|
+
Trimergo timesheet API. It supports querying timesheets using either legacy
|
|
7
|
+
keyword arguments or a PageInfo object that mirrors Trimergo's JavaScript
|
|
8
|
+
PageInfo for precise query construction.
|
|
9
|
+
|
|
10
|
+
Key Goals:
|
|
11
|
+
1. Easy and flexible to use: The client is easy to use in both a pythonic way and a more close to API way.
|
|
12
|
+
2. Consistent query generation: Utilizes a Python port of Trimergo's
|
|
13
|
+
PageInfo, Criterion, and Sorter to ensure query payloads match those
|
|
14
|
+
generated by browser interactions.
|
|
15
|
+
3. Advanced usage: Offers direct access to the PageInfo builder for crafting
|
|
16
|
+
complex AND/OR filter combinations.
|
|
17
|
+
|
|
18
|
+
Usage Examples:
|
|
19
|
+
Pythonic Keyword Arguments (kwargs):
|
|
20
|
+
```python
|
|
21
|
+
from trimergo_client import Trimergo # Assuming Trimergo client is available
|
|
22
|
+
tgo_client = Trimergo(interface_id='1', test_environment=False, system_type='source')
|
|
23
|
+
sheet = Timesheet(tgo_client)
|
|
24
|
+
df = sheet.get(tdate__gt="20250101")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
PageInfo Builder (similar to Trimergo's JS documentation):
|
|
28
|
+
```python
|
|
29
|
+
from .pageinfo import PageInfo, Criterion # Assuming pageinfo.py is in the same directory
|
|
30
|
+
|
|
31
|
+
pi = PageInfo()
|
|
32
|
+
pi.addCriterion(Criterion(field="last_name", operator="=", value="Doe"))
|
|
33
|
+
# pi.addSorter(Sorter(field="last_name", direction=Sorter.ASC)) # Sorter not used in this file
|
|
34
|
+
df = sheet.get(pageinfo=pi)
|
|
35
|
+
```
|
|
36
|
+
"""
|
|
37
|
+
# Standard library imports
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
import re
|
|
40
|
+
import urllib.parse
|
|
41
|
+
from typing import Any, Dict, List, Optional
|
|
42
|
+
|
|
43
|
+
# Third-party imports
|
|
44
|
+
import pandas as pd
|
|
45
|
+
import requests
|
|
46
|
+
|
|
47
|
+
# Local imports
|
|
48
|
+
from .schemas.timesheet import TimeSheetSchema
|
|
49
|
+
from . pageinfo_wrapper.pageinfo import PageInfo, Criterion
|
|
50
|
+
from brynq_sdk_functions import Functions
|
|
51
|
+
|
|
52
|
+
class Timesheet:
|
|
53
|
+
"""Service wrapper for Trimergo timesheets with dual query styles."""
|
|
54
|
+
_OP_SUFFIX: Dict[str, str] = {
|
|
55
|
+
"eq": "=",
|
|
56
|
+
"neq": "!=",
|
|
57
|
+
"gt": ">",
|
|
58
|
+
"lt": "<",
|
|
59
|
+
"gte": ">=",
|
|
60
|
+
"lte": "<=",
|
|
61
|
+
"contains": "LIKE",
|
|
62
|
+
"startswith": "STARTSWITH",
|
|
63
|
+
"endswith": "ENDSWITH",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_DEFAULT_FIELD_STRINGS: List[str] = [
|
|
67
|
+
'ts.tdate', 'ts.time_from', 'ts.time_to', 'ts.notes',
|
|
68
|
+
'ts.show_on_reportready', 'ts.amount', 'pl.user_type',
|
|
69
|
+
'pl.installation_key', 'pr.type_name', 'pr.project_number',
|
|
70
|
+
'pr.description', 'r.relation_key', 'wt.description',
|
|
71
|
+
'res.description', 'sys.fn_varbintohexstr(ts.row_versionid)'
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
def __init__(self, trimergo_client: Any) -> None:
|
|
75
|
+
self._tgo: Any = trimergo_client # expects .trimergo_host & .trimergo_session
|
|
76
|
+
self._allowed: List[Dict[str, str]] = self._load_allowed_fields()
|
|
77
|
+
|
|
78
|
+
def get(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
pageinfo: Optional[PageInfo] = None,
|
|
82
|
+
page: int = 1,
|
|
83
|
+
rpp: int = 1000,
|
|
84
|
+
map_type: str = "tiny",
|
|
85
|
+
max_pages: int = int(1e9),
|
|
86
|
+
**filters: Any,
|
|
87
|
+
) -> pd.DataFrame:
|
|
88
|
+
"""Retrieves timesheet data from Trimergo.
|
|
89
|
+
|
|
90
|
+
If `pageinfo` is supplied, `filters` are ignored. Otherwise, `filters`
|
|
91
|
+
are used to construct a PageInfo object internally.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
pageinfo: An optional PageInfo object for precise query control.
|
|
95
|
+
page: The page number to start fetching from (1-based).
|
|
96
|
+
rpp: Records per page.
|
|
97
|
+
map_type: The map type for the API request (e.g., "tiny").
|
|
98
|
+
max_pages: Maximum number of pages to fetch.
|
|
99
|
+
**filters: Keyword arguments for filtering (legacy method).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A pandas DataFrame containing the timesheet records.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If both `pageinfo` and `filters` are provided.
|
|
106
|
+
"""
|
|
107
|
+
if pageinfo and filters:
|
|
108
|
+
raise ValueError("Pass either 'pageinfo' or filter kwargs, not both.")
|
|
109
|
+
|
|
110
|
+
current_pageinfo: PageInfo
|
|
111
|
+
if pageinfo is None:
|
|
112
|
+
current_pageinfo = self._kwargs_to_pageinfo(filters, page, rpp)
|
|
113
|
+
else:
|
|
114
|
+
current_pageinfo = pageinfo
|
|
115
|
+
current_pageinfo.pageNo = page
|
|
116
|
+
current_pageinfo.rpp = rpp
|
|
117
|
+
|
|
118
|
+
params = {"map": map_type, **current_pageinfo.getProperties()}
|
|
119
|
+
df = self._fetch_pages(params=params, start_page=page, max_pages=max_pages)
|
|
120
|
+
valid_timesheet, invalid_timesheet = Functions.validate_data(df=df, schema=TimeSheetSchema, debug=True)
|
|
121
|
+
return valid_timesheet, invalid_timesheet
|
|
122
|
+
|
|
123
|
+
# Private helpers
|
|
124
|
+
def _kwargs_to_pageinfo(
|
|
125
|
+
self, filters: Dict[str, Any], page: int, rpp: int
|
|
126
|
+
) -> PageInfo:
|
|
127
|
+
"""Translates legacy keyword arguments into a PageInfo object."""
|
|
128
|
+
allowed_vars = {d["variable"]: d for d in self._allowed}
|
|
129
|
+
pi = PageInfo()
|
|
130
|
+
pi.pageNo = page
|
|
131
|
+
pi.rpp = rpp
|
|
132
|
+
|
|
133
|
+
for idx, (raw_key, value) in enumerate(filters.items()):
|
|
134
|
+
op = "="
|
|
135
|
+
field_name = raw_key
|
|
136
|
+
if "__" in raw_key:
|
|
137
|
+
base, suffix = raw_key.rsplit("__", 1)
|
|
138
|
+
if suffix in self._OP_SUFFIX:
|
|
139
|
+
op = self._OP_SUFFIX[suffix]
|
|
140
|
+
field_name = base
|
|
141
|
+
|
|
142
|
+
base_var = field_name.split(".")[-1]
|
|
143
|
+
if base_var not in allowed_vars:
|
|
144
|
+
raise ValueError(f"Illegal filter field: {raw_key!r}")
|
|
145
|
+
|
|
146
|
+
prefix = allowed_vars[base_var]["prefix"]
|
|
147
|
+
if "." not in field_name and prefix:
|
|
148
|
+
field_name = f"{prefix}.{field_name}"
|
|
149
|
+
|
|
150
|
+
crit = Criterion(field_name, op, value)
|
|
151
|
+
if idx == 0:
|
|
152
|
+
pi.addCriterion(crit) # seed
|
|
153
|
+
else:
|
|
154
|
+
pi.wrapCriterion(crit) # nest → logical AND
|
|
155
|
+
return pi
|
|
156
|
+
|
|
157
|
+
def _fetch_and_validate_page_data(
|
|
158
|
+
self,
|
|
159
|
+
url: str,
|
|
160
|
+
params: Dict[str, str],
|
|
161
|
+
page_num: int
|
|
162
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
163
|
+
"""Fetches a single page of data and performs initial validation."""
|
|
164
|
+
try:
|
|
165
|
+
resp = self._tgo.trimergo_session.get(url, params=params)
|
|
166
|
+
resp.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
|
|
167
|
+
data = resp.json()
|
|
168
|
+
except requests.exceptions.HTTPError as http_err:
|
|
169
|
+
print(f"HTTP error fetching page {page_num} ({url}): {http_err}")
|
|
170
|
+
return None
|
|
171
|
+
except requests.exceptions.JSONDecodeError as json_err:
|
|
172
|
+
print(f"JSON decode error for page {page_num} ({url}): {json_err}")
|
|
173
|
+
return None
|
|
174
|
+
except requests.exceptions.RequestException as req_err: # Catches other request-related errors
|
|
175
|
+
print(f"Request error fetching page {page_num} ({url}): {req_err}")
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
if not isinstance(data, list):
|
|
179
|
+
print(f"API Error: Expected list, got {type(data)} for page {page_num}.")
|
|
180
|
+
return None
|
|
181
|
+
return data
|
|
182
|
+
|
|
183
|
+
def _fetch_pages(
|
|
184
|
+
self,
|
|
185
|
+
params: Dict[str, str],
|
|
186
|
+
*,
|
|
187
|
+
start_page: int = 1,
|
|
188
|
+
max_pages: int = int(1e9),
|
|
189
|
+
) -> pd.DataFrame:
|
|
190
|
+
"""Crawls paginated Trimergo /timesheet endpoint; returns flat DataFrame."""
|
|
191
|
+
rpp = int(params.get("rpp", 1000))
|
|
192
|
+
page_num = start_page
|
|
193
|
+
records: List[Dict[str, Any]] = []
|
|
194
|
+
|
|
195
|
+
while page_num <= max_pages:
|
|
196
|
+
url = urllib.parse.urljoin(
|
|
197
|
+
self._tgo.trimergo_host,
|
|
198
|
+
f"/tri-server-gateway-ws/api/rest/timesheet/page/{page_num}",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
page_data = self._fetch_and_validate_page_data(url, params, page_num)
|
|
202
|
+
|
|
203
|
+
if page_data is None: # Error occurred or invalid data structure
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
if not page_data: # Empty list, means no more data
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
for item in page_data:
|
|
210
|
+
if isinstance(item, dict) and "timesheet" in item:
|
|
211
|
+
records.append(self._flatten_dict(item["timesheet"]))
|
|
212
|
+
else:
|
|
213
|
+
# Log or handle malformed item if necessary
|
|
214
|
+
print(f"Warning: Malformed item on page {page_num}: {item}")
|
|
215
|
+
|
|
216
|
+
if len(page_data) < rpp:
|
|
217
|
+
break # Last page fetched
|
|
218
|
+
page_num += 1
|
|
219
|
+
return pd.DataFrame.from_records(records)
|
|
220
|
+
|
|
221
|
+
def _get_nested_value(
|
|
222
|
+
self, data: Dict[str, Any], path: List[str], default: Any = None
|
|
223
|
+
) -> Any:
|
|
224
|
+
"""Safely retrieves a value from a nested dictionary path."""
|
|
225
|
+
current_level = data
|
|
226
|
+
for key in path:
|
|
227
|
+
if not isinstance(current_level, dict):
|
|
228
|
+
# print(f"Warning: Path traversal failed. Expected dict at segment "
|
|
229
|
+
# f"{key_index} ('{key}'), got {type(current_level)}.")
|
|
230
|
+
return default
|
|
231
|
+
current_level = current_level.get(key)
|
|
232
|
+
if current_level is None:
|
|
233
|
+
# print(f"Warning: Path key '{key}' (segment {key_index}) not found.")
|
|
234
|
+
return default
|
|
235
|
+
return current_level
|
|
236
|
+
|
|
237
|
+
def _flatten_dict(
|
|
238
|
+
self, d: Dict[str, Any], parent: str = "", sep: str = "_"
|
|
239
|
+
) -> Dict[str, Any]:
|
|
240
|
+
"""Recursively flattens nested dicts."""
|
|
241
|
+
items: Dict[str, Any] = {}
|
|
242
|
+
for k, v in d.items():
|
|
243
|
+
new_key = f"{parent}{sep}{k}" if parent else k
|
|
244
|
+
if isinstance(v, dict) and v:
|
|
245
|
+
items.update(self._flatten_dict(v, new_key, sep))
|
|
246
|
+
elif isinstance(v, list):
|
|
247
|
+
items[new_key] = str(v)
|
|
248
|
+
elif not isinstance(v, dict): # Add if not dict (to include non-empty values)
|
|
249
|
+
items[new_key] = v
|
|
250
|
+
return items
|
|
251
|
+
|
|
252
|
+
def _parse_raw_field_strings(self, raw_strings: List[str]) -> List[Dict[str, str]]:
|
|
253
|
+
"""Parses a list of raw field strings into dictionary descriptors."""
|
|
254
|
+
parsed_fields: List[Dict[str, str]] = []
|
|
255
|
+
for field_str in raw_strings:
|
|
256
|
+
try:
|
|
257
|
+
parsed_fields.append(self._parse_field_descriptor(field_str))
|
|
258
|
+
except ValueError as ve:
|
|
259
|
+
print(
|
|
260
|
+
f"Warning: Failed to parse field descriptor '{field_str}': {ve}. "
|
|
261
|
+
f"Skipping."
|
|
262
|
+
)
|
|
263
|
+
return parsed_fields
|
|
264
|
+
|
|
265
|
+
def _extract_fields_from_openapi_desc(self) -> List[str]:
|
|
266
|
+
"""Attempts to extract filter field strings from OpenAPI description."""
|
|
267
|
+
if not (
|
|
268
|
+
self._tgo
|
|
269
|
+
and hasattr(self._tgo, 'openapi_json')
|
|
270
|
+
and isinstance(self._tgo.openapi_json, dict)
|
|
271
|
+
):
|
|
272
|
+
return []
|
|
273
|
+
|
|
274
|
+
description_path: List[str] = [
|
|
275
|
+
"paths", "/api/rest/*/timesheet/page/{pag}", "get", "description"
|
|
276
|
+
]
|
|
277
|
+
description: Any = self._get_nested_value(
|
|
278
|
+
self._tgo.openapi_json, description_path, default=""
|
|
279
|
+
)
|
|
280
|
+
if not isinstance(description, str):
|
|
281
|
+
description = "" # Ensure description is a string for .split later
|
|
282
|
+
|
|
283
|
+
marker: str = "You can filter on these fields:"
|
|
284
|
+
if marker in description:
|
|
285
|
+
fields_part = description.split(marker, 1)[1]
|
|
286
|
+
return [f.strip() for f in fields_part.split(",") if f.strip()]
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
def _load_allowed_fields(self) -> List[Dict[str, str]]:
|
|
290
|
+
"""Loads allowed filter fields for Trimergo timesheets."""
|
|
291
|
+
raw_field_strings: List[str] = []
|
|
292
|
+
try:
|
|
293
|
+
raw_field_strings = self._extract_fields_from_openapi_desc()
|
|
294
|
+
except Exception as e: # Catch any error during OpenAPI extraction
|
|
295
|
+
print(
|
|
296
|
+
f"Error: Unexpected issue extracting fields from OpenAPI: {e}. "
|
|
297
|
+
f"Using default field strings."
|
|
298
|
+
)
|
|
299
|
+
# Fallback to default strings if OpenAPI extraction fails
|
|
300
|
+
raw_field_strings = self._DEFAULT_FIELD_STRINGS
|
|
301
|
+
|
|
302
|
+
if not raw_field_strings:
|
|
303
|
+
# print(
|
|
304
|
+
# "Info: No fields from OpenAPI or extraction failed cleanly. "
|
|
305
|
+
# "Using default field strings."
|
|
306
|
+
# )
|
|
307
|
+
raw_field_strings = self._DEFAULT_FIELD_STRINGS
|
|
308
|
+
|
|
309
|
+
parsed_fields = self._parse_raw_field_strings(raw_field_strings)
|
|
310
|
+
|
|
311
|
+
if not parsed_fields:
|
|
312
|
+
# This means all attempts (OpenAPI or default) yielded no parsable fields.
|
|
313
|
+
# This is critical if even defaults fail.
|
|
314
|
+
print(
|
|
315
|
+
"Critical Warning: All field strings (OpenAPI or default) failed to parse. "
|
|
316
|
+
"Attempting to parse hardcoded defaults again as a final fallback."
|
|
317
|
+
)
|
|
318
|
+
# Try parsing default strings one last time if all else failed
|
|
319
|
+
parsed_fields = self._parse_raw_field_strings(self._DEFAULT_FIELD_STRINGS)
|
|
320
|
+
if not parsed_fields:
|
|
321
|
+
# If even this fails, something is fundamentally wrong with parsing logic or defaults
|
|
322
|
+
raise RuntimeError(
|
|
323
|
+
"Failed to parse any field descriptors, including hardcoded defaults. "
|
|
324
|
+
"Timesheet filtering will be severely impacted."
|
|
325
|
+
)
|
|
326
|
+
return parsed_fields
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _parse_field_descriptor(d: str) -> Dict[str, str]:
|
|
330
|
+
"""Parses a Trimergo field descriptor string into its prefix and variable name."""
|
|
331
|
+
desc_str = d.strip()
|
|
332
|
+
match = re.search(r'\((.*)\)', desc_str)
|
|
333
|
+
if match:
|
|
334
|
+
inner_content = match.group(1)
|
|
335
|
+
if '.' in inner_content:
|
|
336
|
+
desc_str = inner_content.strip()
|
|
337
|
+
|
|
338
|
+
if "." not in desc_str:
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"Field '{d}' malformed: expected 'prefix.variable' (got '{desc_str}')."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
prefix, variable_part = desc_str.split(".", 1)
|
|
344
|
+
variable_name = variable_part.split(".")[0]
|
|
345
|
+
|
|
346
|
+
if not prefix.strip() or not variable_name.strip():
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"Field '{d}' -> empty prefix/var (pf='{prefix}',var='{variable_name}')."
|
|
349
|
+
)
|
|
350
|
+
return {"prefix": prefix.strip(), "variable": variable_name.strip()}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brynq_sdk_trimergo
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Trimergo wrapper from BrynQ
|
|
5
|
+
Author: BrynQ
|
|
6
|
+
Author-email: support@brynq.com
|
|
7
|
+
License: BrynQ License
|
|
8
|
+
Requires-Dist: brynq-sdk-brynq<5,>=4
|
|
9
|
+
Requires-Dist: pandas<3.0.0,>=2.2.0
|
|
10
|
+
Dynamic: author
|
|
11
|
+
Dynamic: author-email
|
|
12
|
+
Dynamic: description
|
|
13
|
+
Dynamic: license
|
|
14
|
+
Dynamic: requires-dist
|
|
15
|
+
Dynamic: summary
|
|
16
|
+
|
|
17
|
+
Trimergo wrapper from BrynQ
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
brynq_sdk_trimergo/__init__.py,sha256=AiVGBhn27hbqAlmQk2TC2qCajLQ1wz3ZC8jalPRPi10,8329
|
|
2
|
+
brynq_sdk_trimergo/timesheet.py,sha256=oVvNy0-EVEH0FfLYV7Lr8ijpl9RtFBsp9vzPEYHwKPQ,14048
|
|
3
|
+
brynq_sdk_trimergo/pageinfo_wrapper/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
brynq_sdk_trimergo/pageinfo_wrapper/pageinfo.py,sha256=qAeLIDqOlCs_P-Riof_Pa_AE2bfo5zIr3CCdku0WL3U,5691
|
|
5
|
+
brynq_sdk_trimergo/schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
brynq_sdk_trimergo/schemas/timesheet.py,sha256=YGKbjfumAscx7oaucPlCfOwYd0gVT9f_BveHplK56JE,7761
|
|
7
|
+
brynq_sdk_trimergo-1.0.0.dist-info/METADATA,sha256=zS_SvgrZmpyGyPjUSFh5RCnofn5GV9s4X9nU8jcq0js,386
|
|
8
|
+
brynq_sdk_trimergo-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
9
|
+
brynq_sdk_trimergo-1.0.0.dist-info/top_level.txt,sha256=Sra4UJhCvcdQtwLtLX9rtXwdJIDdXnz6g8bZXi00jxY,19
|
|
10
|
+
brynq_sdk_trimergo-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
brynq_sdk_trimergo
|