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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ brynq_sdk_trimergo