ergon-framework-python 0.1.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.
- ergon/__init__.py +13 -0
- ergon/bootstrap/src/__project__/__init__.py +0 -0
- ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
- ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
- ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
- ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
- ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
- ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
- ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
- ergon/bootstrap/src/__project__/main.py +9 -0
- ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
- ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
- ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
- ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
- ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
- ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
- ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
- ergon/cli.py +174 -0
- ergon/connector/__init__.py +64 -0
- ergon/connector/connector.py +97 -0
- ergon/connector/excel/__init__.py +18 -0
- ergon/connector/excel/connector.py +175 -0
- ergon/connector/excel/models.py +24 -0
- ergon/connector/excel/service.py +98 -0
- ergon/connector/pipefy/__init__.py +21 -0
- ergon/connector/pipefy/async_connector.py +48 -0
- ergon/connector/pipefy/async_service.py +907 -0
- ergon/connector/pipefy/connector.py +36 -0
- ergon/connector/pipefy/models.py +48 -0
- ergon/connector/pipefy/service.py +1016 -0
- ergon/connector/pipefy/version.py +1 -0
- ergon/connector/postgres/__init__.py +11 -0
- ergon/connector/postgres/async_connector.py +119 -0
- ergon/connector/postgres/async_service.py +116 -0
- ergon/connector/postgres/models.py +34 -0
- ergon/connector/rabbitmq/__init__.py +25 -0
- ergon/connector/rabbitmq/async_connector.py +120 -0
- ergon/connector/rabbitmq/async_service.py +417 -0
- ergon/connector/rabbitmq/connector.py +54 -0
- ergon/connector/rabbitmq/helper.py +14 -0
- ergon/connector/rabbitmq/models.py +92 -0
- ergon/connector/rabbitmq/service.py +199 -0
- ergon/connector/sqs/__init__.py +15 -0
- ergon/connector/sqs/async_connector.py +120 -0
- ergon/connector/sqs/async_service.py +246 -0
- ergon/connector/sqs/connector.py +120 -0
- ergon/connector/sqs/models.py +36 -0
- ergon/connector/sqs/service.py +219 -0
- ergon/connector/transaction.py +14 -0
- ergon/py.typed +0 -0
- ergon/service/__init__.py +5 -0
- ergon/service/service.py +17 -0
- ergon/task/__init__.py +13 -0
- ergon/task/base.py +222 -0
- ergon/task/exceptions.py +217 -0
- ergon/task/helpers.py +691 -0
- ergon/task/manager.py +85 -0
- ergon/task/mixins/__init__.py +13 -0
- ergon/task/mixins/consumer.py +858 -0
- ergon/task/mixins/metrics.py +457 -0
- ergon/task/mixins/producer.py +486 -0
- ergon/task/policies.py +229 -0
- ergon/task/runner.py +386 -0
- ergon/task/utils.py +64 -0
- ergon/telemetry/__init__.py +7 -0
- ergon/telemetry/_resource.py +13 -0
- ergon/telemetry/logging.py +370 -0
- ergon/telemetry/metrics.py +101 -0
- ergon/telemetry/tracing.py +152 -0
- ergon/utils/__init__.py +5 -0
- ergon/utils/env.py +26 -0
- ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
- ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
- ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
- ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
- ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- ergon_framework_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ergon.task.helpers import run_fn_async
|
|
9
|
+
from ergon.task.policies import RetryPolicy
|
|
10
|
+
|
|
11
|
+
from .models import (
|
|
12
|
+
CreateCardInput,
|
|
13
|
+
FieldFilter,
|
|
14
|
+
FieldFilterOperator,
|
|
15
|
+
PipefyClient,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
default_retry = RetryPolicy(max_attempts=5, backoff=1, backoff_multiplier=2, backoff_cap=10)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncPipefyService:
|
|
24
|
+
def __init__(self, client: PipefyClient):
|
|
25
|
+
logger.info("Initializing AsyncPipefyService")
|
|
26
|
+
|
|
27
|
+
self.client = client
|
|
28
|
+
self.endpoint = client.endpoint
|
|
29
|
+
self.timeout_sec = client.timeout_sec
|
|
30
|
+
|
|
31
|
+
self._token: str | None = None
|
|
32
|
+
self._http: httpx.AsyncClient | None = None
|
|
33
|
+
self._after_cursor = None
|
|
34
|
+
self._has_next_page = False
|
|
35
|
+
|
|
36
|
+
async def _get_http(self) -> httpx.AsyncClient:
|
|
37
|
+
if self._http is None or self._http.is_closed:
|
|
38
|
+
self._http = httpx.AsyncClient(timeout=self.timeout_sec)
|
|
39
|
+
return self._http
|
|
40
|
+
|
|
41
|
+
async def close(self) -> None:
|
|
42
|
+
if self._http is not None and not self._http.is_closed:
|
|
43
|
+
await self._http.aclose()
|
|
44
|
+
self._http = None
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------
|
|
47
|
+
# AUTHENTICATION
|
|
48
|
+
# ---------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def get_access_token(self) -> str | None:
|
|
51
|
+
return self._token
|
|
52
|
+
|
|
53
|
+
@run_fn_async(retry=default_retry, trace_name="AsyncPipefyService.authenticate")
|
|
54
|
+
async def authenticate(self) -> None:
|
|
55
|
+
logger.info("Authenticating with Pipefy (async)")
|
|
56
|
+
http = await self._get_http()
|
|
57
|
+
resp = await http.post(
|
|
58
|
+
self.client.oauth_token_url,
|
|
59
|
+
json={
|
|
60
|
+
"grant_type": "client_credentials",
|
|
61
|
+
"client_id": self.client.client_id,
|
|
62
|
+
"client_secret": self.client.client_secret,
|
|
63
|
+
},
|
|
64
|
+
timeout=30,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
resp.raise_for_status()
|
|
68
|
+
data = resp.json()
|
|
69
|
+
self._token = data.get("access_token")
|
|
70
|
+
|
|
71
|
+
logger.info("Authenticated successfully (async)")
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------
|
|
74
|
+
# GENERIC GRAPHQL
|
|
75
|
+
# ---------------------------------------------------------
|
|
76
|
+
async def _graphql(self, query: str, variables: Dict[str, Any]) -> Dict[str, Any]:
|
|
77
|
+
if not self._token:
|
|
78
|
+
raise ValueError("Authentication required")
|
|
79
|
+
|
|
80
|
+
http = await self._get_http()
|
|
81
|
+
payload = {"query": query, "variables": variables}
|
|
82
|
+
headers = {
|
|
83
|
+
"Authorization": f"Bearer {self._token}",
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
resp = await http.post(self.endpoint, json=payload, headers=headers)
|
|
88
|
+
|
|
89
|
+
logger.debug(f"GraphQL query response: {resp.status_code}")
|
|
90
|
+
|
|
91
|
+
if resp.status_code == 401:
|
|
92
|
+
logger.debug("Unauthorized. Re-authenticating with Pipefy (async)")
|
|
93
|
+
await self.authenticate()
|
|
94
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
95
|
+
resp = await http.post(self.endpoint, json=payload, headers=headers)
|
|
96
|
+
logger.debug("Re-authenticated successfully (async)")
|
|
97
|
+
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
data = resp.json()
|
|
100
|
+
|
|
101
|
+
logger.debug("GraphQL query completed successfully")
|
|
102
|
+
return data.get("data", {})
|
|
103
|
+
|
|
104
|
+
@run_fn_async(retry=default_retry)
|
|
105
|
+
async def get_pipe_fields(
|
|
106
|
+
self,
|
|
107
|
+
pipe_id: str,
|
|
108
|
+
response_fields: Optional[str] = None,
|
|
109
|
+
) -> List[Dict[str, Any]]:
|
|
110
|
+
if not pipe_id:
|
|
111
|
+
raise RuntimeError("pipe_id must be provided")
|
|
112
|
+
|
|
113
|
+
if response_fields is None:
|
|
114
|
+
response_fields = """
|
|
115
|
+
id
|
|
116
|
+
name
|
|
117
|
+
phases {
|
|
118
|
+
id
|
|
119
|
+
name
|
|
120
|
+
fields {
|
|
121
|
+
id
|
|
122
|
+
label
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
query = f"""
|
|
128
|
+
query GetPipeFields($pipeId: ID!) {{
|
|
129
|
+
pipe(id: $pipeId) {{
|
|
130
|
+
{response_fields}
|
|
131
|
+
}}
|
|
132
|
+
}}
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
data = await self._graphql(query, {"pipeId": pipe_id})
|
|
136
|
+
|
|
137
|
+
pipe = data.get("pipe") or {}
|
|
138
|
+
phases = pipe.get("phases") or []
|
|
139
|
+
|
|
140
|
+
total_fields = sum(len(p.get("fields", [])) for p in phases)
|
|
141
|
+
logger.info(
|
|
142
|
+
"Retrieved %d phases and %d total fields from pipe %s",
|
|
143
|
+
len(phases),
|
|
144
|
+
total_fields,
|
|
145
|
+
pipe_id,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
result = []
|
|
149
|
+
for phase in phases:
|
|
150
|
+
for f in phase.get("fields", []):
|
|
151
|
+
result.append(
|
|
152
|
+
{
|
|
153
|
+
"phase_id": phase.get("id"),
|
|
154
|
+
"phase_name": phase.get("name"),
|
|
155
|
+
"id": f.get("id"),
|
|
156
|
+
"label": f.get("label"),
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
# -------------------------------------------------------------
|
|
163
|
+
@run_fn_async(retry=default_retry)
|
|
164
|
+
async def get_pipe_start_form_fields(
|
|
165
|
+
self,
|
|
166
|
+
pipe_id: str,
|
|
167
|
+
response_fields: Optional[str] = None,
|
|
168
|
+
) -> List[Dict[str, Any]]:
|
|
169
|
+
if not pipe_id:
|
|
170
|
+
raise RuntimeError("pipe_id must be provided")
|
|
171
|
+
|
|
172
|
+
if response_fields is None:
|
|
173
|
+
response_fields = """
|
|
174
|
+
id
|
|
175
|
+
name
|
|
176
|
+
start_form_fields {
|
|
177
|
+
id
|
|
178
|
+
label
|
|
179
|
+
}
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
query = f"""
|
|
183
|
+
query GetStartFormFields($pipeId: ID!) {{
|
|
184
|
+
pipe(id: $pipeId) {{
|
|
185
|
+
{response_fields}
|
|
186
|
+
}}
|
|
187
|
+
}}
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
data = await self._graphql(query, {"pipeId": pipe_id})
|
|
191
|
+
|
|
192
|
+
pipe = data.get("pipe") or {}
|
|
193
|
+
fields = pipe.get("start_form_fields") or []
|
|
194
|
+
|
|
195
|
+
result = [
|
|
196
|
+
{
|
|
197
|
+
"phase_id": pipe.get("id"),
|
|
198
|
+
"phase_name": pipe.get("name"),
|
|
199
|
+
"id": f.get("id"),
|
|
200
|
+
"label": f.get("label"),
|
|
201
|
+
}
|
|
202
|
+
for f in fields
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
logger.info(
|
|
206
|
+
"Retrieved %d start_form_fields from pipe %s (%s)",
|
|
207
|
+
len(fields),
|
|
208
|
+
pipe_id,
|
|
209
|
+
pipe.get("name"),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------
|
|
215
|
+
# CARD QUERIES
|
|
216
|
+
# ---------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
@run_fn_async(retry=default_retry)
|
|
219
|
+
async def get_card_by_id(
|
|
220
|
+
self,
|
|
221
|
+
card_id: str,
|
|
222
|
+
response_fields: Optional[str] = None,
|
|
223
|
+
) -> Dict:
|
|
224
|
+
if response_fields is None:
|
|
225
|
+
response_fields = """
|
|
226
|
+
id
|
|
227
|
+
title
|
|
228
|
+
fields {
|
|
229
|
+
field { id }
|
|
230
|
+
name
|
|
231
|
+
value
|
|
232
|
+
}
|
|
233
|
+
current_phase { id name }
|
|
234
|
+
updated_at
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
query = f"""
|
|
238
|
+
query GetCard($id: ID!) {{
|
|
239
|
+
card(id: $id) {{
|
|
240
|
+
{response_fields}
|
|
241
|
+
}}
|
|
242
|
+
}}
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
data = await self._graphql(query, {"id": card_id})
|
|
246
|
+
return data.get("card", {})
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
@run_fn_async(retry=default_retry)
|
|
251
|
+
async def get_next_card(
|
|
252
|
+
self,
|
|
253
|
+
phase_id: str,
|
|
254
|
+
field_filters: Optional[List[FieldFilter]] = None,
|
|
255
|
+
batch_size: int = 1,
|
|
256
|
+
response_fields: Optional[str] = None,
|
|
257
|
+
) -> Optional[List[Dict]]:
|
|
258
|
+
if response_fields is None:
|
|
259
|
+
response_fields = """
|
|
260
|
+
id
|
|
261
|
+
title
|
|
262
|
+
fields {
|
|
263
|
+
field { id }
|
|
264
|
+
name
|
|
265
|
+
value
|
|
266
|
+
}
|
|
267
|
+
current_phase { id name }
|
|
268
|
+
updated_at
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
query = f"""
|
|
272
|
+
query GetCardsFromPhase($phaseId: ID!, $after: String, $batch_size: Int!) {{
|
|
273
|
+
phase(id: $phaseId) {{
|
|
274
|
+
id
|
|
275
|
+
name
|
|
276
|
+
cards(first: $batch_size, after: $after) {{
|
|
277
|
+
edges {{
|
|
278
|
+
node {{
|
|
279
|
+
{response_fields}
|
|
280
|
+
}}
|
|
281
|
+
}}
|
|
282
|
+
pageInfo {{
|
|
283
|
+
hasNextPage
|
|
284
|
+
endCursor
|
|
285
|
+
}}
|
|
286
|
+
}}
|
|
287
|
+
}}
|
|
288
|
+
}}
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
data = await self._graphql(
|
|
292
|
+
query,
|
|
293
|
+
{"phaseId": phase_id, "after": self._after_cursor, "batch_size": batch_size},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
phase = data.get("phase", {})
|
|
297
|
+
if not phase:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
cards = phase.get("cards", {}).get("edges", [])
|
|
301
|
+
if not cards:
|
|
302
|
+
self._after_cursor = None
|
|
303
|
+
self._has_next_page = False
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
page_info = phase.get("cards", {}).get("pageInfo", {})
|
|
307
|
+
self._after_cursor = page_info.get("endCursor")
|
|
308
|
+
self._has_next_page = page_info.get("hasNextPage")
|
|
309
|
+
|
|
310
|
+
filtered = []
|
|
311
|
+
for edge in cards:
|
|
312
|
+
card = edge.get("node", {})
|
|
313
|
+
if self.__apply_client_side_filter(card, field_filters):
|
|
314
|
+
filtered.append(card)
|
|
315
|
+
|
|
316
|
+
return filtered or None
|
|
317
|
+
|
|
318
|
+
@run_fn_async(retry=default_retry)
|
|
319
|
+
async def search_cards_by_field(
|
|
320
|
+
self,
|
|
321
|
+
pipe_id: str,
|
|
322
|
+
field_id: str,
|
|
323
|
+
field_value: str,
|
|
324
|
+
response_fields: Optional[str] = None,
|
|
325
|
+
) -> List[Dict]:
|
|
326
|
+
if not pipe_id:
|
|
327
|
+
raise RuntimeError("pipe_id must be provided")
|
|
328
|
+
if not field_id:
|
|
329
|
+
raise RuntimeError("field_id must be provided")
|
|
330
|
+
|
|
331
|
+
if response_fields is None:
|
|
332
|
+
response_fields = """
|
|
333
|
+
id
|
|
334
|
+
title
|
|
335
|
+
updated_at
|
|
336
|
+
current_phase { id name }
|
|
337
|
+
fields {
|
|
338
|
+
field { id }
|
|
339
|
+
name
|
|
340
|
+
value
|
|
341
|
+
}
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
query = f"""
|
|
345
|
+
query FindCards($pipeId: ID!, $fieldId: String!, $fieldValue: String!) {{
|
|
346
|
+
findCards(
|
|
347
|
+
pipeId: $pipeId,
|
|
348
|
+
search: {{ fieldId: $fieldId, fieldValue: $fieldValue }}
|
|
349
|
+
) {{
|
|
350
|
+
edges {{
|
|
351
|
+
node {{
|
|
352
|
+
{response_fields}
|
|
353
|
+
}}
|
|
354
|
+
}}
|
|
355
|
+
}}
|
|
356
|
+
}}
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
variables = {
|
|
360
|
+
"pipeId": str(pipe_id),
|
|
361
|
+
"fieldId": field_id,
|
|
362
|
+
"fieldValue": field_value,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
data = await self._graphql(query, variables)
|
|
366
|
+
edges = ((data.get("findCards") or {}).get("edges")) or []
|
|
367
|
+
|
|
368
|
+
cards = [edge.get("node", {}) for edge in edges]
|
|
369
|
+
|
|
370
|
+
logger.info(
|
|
371
|
+
"Found %d cards in pipe %s where field %s == %s",
|
|
372
|
+
len(cards),
|
|
373
|
+
pipe_id,
|
|
374
|
+
field_id,
|
|
375
|
+
field_value,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return cards
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------
|
|
381
|
+
# CREATE CARD
|
|
382
|
+
# ---------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
@run_fn_async(retry=default_retry)
|
|
385
|
+
async def create_card(
|
|
386
|
+
self,
|
|
387
|
+
card: CreateCardInput,
|
|
388
|
+
response_fields: Optional[str] = None,
|
|
389
|
+
) -> Dict:
|
|
390
|
+
if response_fields is None:
|
|
391
|
+
response_fields = """
|
|
392
|
+
id
|
|
393
|
+
title
|
|
394
|
+
current_phase { id name }
|
|
395
|
+
fields { name value field { id type } }
|
|
396
|
+
created_at
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
mutation = f"""
|
|
400
|
+
mutation CreateCard($input: CreateCardInput!) {{
|
|
401
|
+
createCard(input: $input) {{
|
|
402
|
+
card {{
|
|
403
|
+
{response_fields}
|
|
404
|
+
}}
|
|
405
|
+
}}
|
|
406
|
+
}}
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
variables = {"input": card.model_dump()}
|
|
410
|
+
data = await self._graphql(mutation, variables)
|
|
411
|
+
|
|
412
|
+
return (data.get("createCard") or {}).get("card") or {}
|
|
413
|
+
|
|
414
|
+
# ---------------------------------------------------------
|
|
415
|
+
# FIELD HELPERS
|
|
416
|
+
# ---------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
def get_field_value_by_name(self, card: Dict, field_name: str) -> Any:
|
|
419
|
+
for field in card.get("fields", []):
|
|
420
|
+
if field.get("name") == field_name:
|
|
421
|
+
try:
|
|
422
|
+
is_list = json.loads(field.get("value"))
|
|
423
|
+
if isinstance(is_list, list):
|
|
424
|
+
if is_list:
|
|
425
|
+
return is_list
|
|
426
|
+
else:
|
|
427
|
+
return None
|
|
428
|
+
else:
|
|
429
|
+
return field.get("value")
|
|
430
|
+
except Exception:
|
|
431
|
+
return field.get("value")
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
def get_field_value_by_id(self, card: Dict, field_id: str) -> Any:
|
|
435
|
+
for field in card.get("fields", []):
|
|
436
|
+
if field.get("field", {}).get("id", {}) == field_id:
|
|
437
|
+
try:
|
|
438
|
+
is_list = json.loads(field.get("value"))
|
|
439
|
+
if isinstance(is_list, list):
|
|
440
|
+
if is_list:
|
|
441
|
+
return is_list
|
|
442
|
+
else:
|
|
443
|
+
return None
|
|
444
|
+
else:
|
|
445
|
+
return field.get("value")
|
|
446
|
+
except Exception:
|
|
447
|
+
return field.get("value")
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
# ---------------------------------------------------------
|
|
451
|
+
# CARD OPERATIONS
|
|
452
|
+
# ---------------------------------------------------------
|
|
453
|
+
@run_fn_async(retry=default_retry)
|
|
454
|
+
async def move_card_to_phase(
|
|
455
|
+
self,
|
|
456
|
+
card_id: str,
|
|
457
|
+
phase_id: str,
|
|
458
|
+
response_fields: str | None = None,
|
|
459
|
+
):
|
|
460
|
+
if not response_fields:
|
|
461
|
+
response_fields = """
|
|
462
|
+
card {
|
|
463
|
+
id
|
|
464
|
+
title
|
|
465
|
+
current_phase {
|
|
466
|
+
id
|
|
467
|
+
name
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
mutation = f"""
|
|
473
|
+
mutation MoveCardToPhase($input: MoveCardToPhaseInput!) {{
|
|
474
|
+
moveCardToPhase(input: $input) {{
|
|
475
|
+
{response_fields}
|
|
476
|
+
}}
|
|
477
|
+
}}
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
variables = {"input": {"card_id": card_id, "destination_phase_id": phase_id}}
|
|
481
|
+
|
|
482
|
+
data = await self._graphql(mutation, variables)
|
|
483
|
+
result = data.get("moveCardToPhase")
|
|
484
|
+
|
|
485
|
+
return result is not None
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------
|
|
488
|
+
@run_fn_async(retry=default_retry)
|
|
489
|
+
async def update_card_fields_by_id(
|
|
490
|
+
self,
|
|
491
|
+
card_id: str,
|
|
492
|
+
fields: Dict[str, Any],
|
|
493
|
+
response_fields: Optional[str] = None,
|
|
494
|
+
) -> Dict:
|
|
495
|
+
if not response_fields:
|
|
496
|
+
response_fields = """
|
|
497
|
+
... on Card {
|
|
498
|
+
id
|
|
499
|
+
title
|
|
500
|
+
fields { name value }
|
|
501
|
+
current_phase { id name }
|
|
502
|
+
}
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
mutation = f"""
|
|
506
|
+
mutation UpdateFieldsValues($input: UpdateFieldsValuesInput!) {{
|
|
507
|
+
updateFieldsValues(input: $input) {{
|
|
508
|
+
success
|
|
509
|
+
updatedNode {{
|
|
510
|
+
{response_fields}
|
|
511
|
+
}}
|
|
512
|
+
userErrors {{
|
|
513
|
+
field
|
|
514
|
+
message
|
|
515
|
+
}}
|
|
516
|
+
}}
|
|
517
|
+
}}
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
field_attrs = [{"fieldId": fid, "value": value} for fid, value in fields.items()]
|
|
521
|
+
|
|
522
|
+
variables = {
|
|
523
|
+
"input": {
|
|
524
|
+
"nodeId": card_id,
|
|
525
|
+
"values": field_attrs,
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
data = await self._graphql(mutation, variables)
|
|
530
|
+
result = data.get("updateFieldsValues") or {}
|
|
531
|
+
|
|
532
|
+
if result.get("userErrors"):
|
|
533
|
+
raise RuntimeError(f"Pipefy update failed: {result['userErrors']}")
|
|
534
|
+
|
|
535
|
+
return result.get("updatedNode") or {}
|
|
536
|
+
|
|
537
|
+
@run_fn_async(retry=default_retry)
|
|
538
|
+
async def update_card_field_by_id(
|
|
539
|
+
self,
|
|
540
|
+
card_id: str,
|
|
541
|
+
field_id: str,
|
|
542
|
+
new_value: str,
|
|
543
|
+
) -> bool:
|
|
544
|
+
mutation = """
|
|
545
|
+
mutation UpdateCardField($input: UpdateCardFieldInput!) {
|
|
546
|
+
updateCardField(input: $input) {
|
|
547
|
+
success
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
variables = {
|
|
553
|
+
"input": {
|
|
554
|
+
"card_id": card_id,
|
|
555
|
+
"field_id": field_id,
|
|
556
|
+
"new_value": new_value,
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
data = await self._graphql(mutation, variables)
|
|
561
|
+
return (data.get("updateCardField") or {}).get("success", False)
|
|
562
|
+
|
|
563
|
+
# ---------------------------------------------------------
|
|
564
|
+
# FILTERS
|
|
565
|
+
# ---------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
def __apply_client_side_filter(
|
|
568
|
+
self,
|
|
569
|
+
card: Dict,
|
|
570
|
+
field_filters: Optional[List[FieldFilter]],
|
|
571
|
+
) -> Optional[Dict]:
|
|
572
|
+
if not field_filters:
|
|
573
|
+
return card
|
|
574
|
+
|
|
575
|
+
card_fields = card.get("fields", [])
|
|
576
|
+
|
|
577
|
+
for filter in field_filters:
|
|
578
|
+
value = next(
|
|
579
|
+
(f.get("value") for f in card_fields if f.get("name") == filter.field),
|
|
580
|
+
None,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
if value is None:
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
if filter.operator == FieldFilterOperator.EQUAL:
|
|
587
|
+
if value != filter.value:
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
return card
|
|
591
|
+
|
|
592
|
+
@run_fn_async(retry=default_retry)
|
|
593
|
+
async def presign_url(
|
|
594
|
+
self,
|
|
595
|
+
org_id: str,
|
|
596
|
+
file_path: str,
|
|
597
|
+
response_fields: Optional[str] = None,
|
|
598
|
+
) -> Dict:
|
|
599
|
+
if not org_id:
|
|
600
|
+
raise RuntimeError("org_id must be provided")
|
|
601
|
+
|
|
602
|
+
if not file_path:
|
|
603
|
+
raise RuntimeError("file_path must be provided")
|
|
604
|
+
|
|
605
|
+
if response_fields is None:
|
|
606
|
+
response_fields = "url"
|
|
607
|
+
|
|
608
|
+
mutation = f"""
|
|
609
|
+
mutation CreatePresignedUrl($input: CreatePresignedUrlInput!) {{
|
|
610
|
+
createPresignedUrl(input: $input) {{
|
|
611
|
+
{response_fields}
|
|
612
|
+
}}
|
|
613
|
+
}}
|
|
614
|
+
"""
|
|
615
|
+
|
|
616
|
+
variables = {
|
|
617
|
+
"input": {
|
|
618
|
+
"organizationId": org_id,
|
|
619
|
+
"fileName": os.path.basename(str(file_path)),
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
data = await self._graphql(mutation, variables)
|
|
624
|
+
result = data.get("createPresignedUrl") or {}
|
|
625
|
+
|
|
626
|
+
logger.debug(
|
|
627
|
+
"Received presigned URL for '%s' in org '%s': %s",
|
|
628
|
+
os.path.basename(file_path),
|
|
629
|
+
org_id,
|
|
630
|
+
result.get("url"),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
return result
|
|
634
|
+
|
|
635
|
+
@run_fn_async(retry=default_retry)
|
|
636
|
+
async def upload_file(
|
|
637
|
+
self,
|
|
638
|
+
presigned_url: Dict,
|
|
639
|
+
file_path: str,
|
|
640
|
+
content_type: str = "application/octet-stream",
|
|
641
|
+
) -> str:
|
|
642
|
+
url = presigned_url.get("url")
|
|
643
|
+
if not url:
|
|
644
|
+
raise RuntimeError("Invalid presigned_url: missing 'url'")
|
|
645
|
+
|
|
646
|
+
if not file_path:
|
|
647
|
+
raise RuntimeError("file_path must be provided")
|
|
648
|
+
|
|
649
|
+
logger.debug("Uploading file '%s' → %s (async)", file_path, url)
|
|
650
|
+
|
|
651
|
+
http = await self._get_http()
|
|
652
|
+
with open(file_path, "rb") as f:
|
|
653
|
+
content = f.read()
|
|
654
|
+
|
|
655
|
+
resp = await http.put(
|
|
656
|
+
url,
|
|
657
|
+
content=content,
|
|
658
|
+
headers={"Content-Type": content_type},
|
|
659
|
+
timeout=60,
|
|
660
|
+
)
|
|
661
|
+
resp.raise_for_status()
|
|
662
|
+
|
|
663
|
+
logger.info("Successfully uploaded file '%s' (async).", file_path)
|
|
664
|
+
|
|
665
|
+
clean_url = url.split("?")[0]
|
|
666
|
+
s3_key = clean_url.split("amazonaws.com/")[-1]
|
|
667
|
+
|
|
668
|
+
logger.debug("Resolved S3 key after upload: %s", s3_key)
|
|
669
|
+
|
|
670
|
+
return s3_key
|
|
671
|
+
|
|
672
|
+
@run_fn_async(retry=default_retry)
|
|
673
|
+
async def get_database_record_by_title(
|
|
674
|
+
self,
|
|
675
|
+
database_id: str,
|
|
676
|
+
title: str,
|
|
677
|
+
limit: int = 100,
|
|
678
|
+
response_fields: Optional[str] = None,
|
|
679
|
+
) -> List[Dict]:
|
|
680
|
+
if response_fields is None:
|
|
681
|
+
response_fields = """
|
|
682
|
+
id
|
|
683
|
+
title
|
|
684
|
+
record_fields {
|
|
685
|
+
name
|
|
686
|
+
value
|
|
687
|
+
field { id }
|
|
688
|
+
}
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
query = f"""
|
|
692
|
+
query GetDatabaseRecords($databaseId: ID!, $first: Int!, $title: String!) {{
|
|
693
|
+
table_records(
|
|
694
|
+
table_id: $databaseId,
|
|
695
|
+
first: $first,
|
|
696
|
+
search: {{ title: $title }}
|
|
697
|
+
) {{
|
|
698
|
+
edges {{
|
|
699
|
+
node {{
|
|
700
|
+
{response_fields}
|
|
701
|
+
}}
|
|
702
|
+
}}
|
|
703
|
+
}}
|
|
704
|
+
}}
|
|
705
|
+
"""
|
|
706
|
+
|
|
707
|
+
variables = {
|
|
708
|
+
"databaseId": str(database_id),
|
|
709
|
+
"first": limit,
|
|
710
|
+
"title": title,
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
data = await self._graphql(query, variables)
|
|
715
|
+
|
|
716
|
+
edges = (data.get("table_records") or {}).get("edges", [])
|
|
717
|
+
records = [edge.get("node", {}) for edge in edges]
|
|
718
|
+
|
|
719
|
+
logger.info(
|
|
720
|
+
"Retrieved %d database records from database %s matching title '%s'",
|
|
721
|
+
len(records),
|
|
722
|
+
database_id,
|
|
723
|
+
title,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
return records
|
|
727
|
+
|
|
728
|
+
except Exception as e:
|
|
729
|
+
logger.error(
|
|
730
|
+
f"Error fetching database records for database {database_id} and title '{title}': {e}",
|
|
731
|
+
exc_info=True,
|
|
732
|
+
)
|
|
733
|
+
return []
|
|
734
|
+
|
|
735
|
+
@run_fn_async(retry=default_retry)
|
|
736
|
+
async def download_card_attachments(
|
|
737
|
+
self,
|
|
738
|
+
field_id: str,
|
|
739
|
+
card_id: Optional[str] = None,
|
|
740
|
+
card: Optional[dict] = None,
|
|
741
|
+
output_dir: str = "attachments",
|
|
742
|
+
) -> List[str]:
|
|
743
|
+
if not card and not card_id:
|
|
744
|
+
raise Exception("Either card or card_id must be passed as parameter")
|
|
745
|
+
|
|
746
|
+
if card_id:
|
|
747
|
+
card = await self.get_card_by_id(card_id)
|
|
748
|
+
|
|
749
|
+
if not card:
|
|
750
|
+
raise Exception("Could not resolve card")
|
|
751
|
+
|
|
752
|
+
attachments = self.get_field_value_by_id(card, field_id)
|
|
753
|
+
|
|
754
|
+
if not attachments:
|
|
755
|
+
logger.warning("No attachments found for field '%s' in card %s", field_id, card_id)
|
|
756
|
+
return []
|
|
757
|
+
|
|
758
|
+
if isinstance(attachments, str):
|
|
759
|
+
attachments = [attachments]
|
|
760
|
+
elif not isinstance(attachments, list):
|
|
761
|
+
logger.error("Unexpected attachments format: %s", type(attachments))
|
|
762
|
+
return []
|
|
763
|
+
|
|
764
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
765
|
+
saved_files = []
|
|
766
|
+
|
|
767
|
+
http = await self._get_http()
|
|
768
|
+
for i, url in enumerate(attachments):
|
|
769
|
+
clean_url = url.split("?")[0]
|
|
770
|
+
original_filename = clean_url.split("/")[-1]
|
|
771
|
+
|
|
772
|
+
filename = f"{i}_{original_filename}"
|
|
773
|
+
file_path = os.path.join(output_dir, filename)
|
|
774
|
+
|
|
775
|
+
logger.info("Downloading attachment %d to %s (async)", i, file_path)
|
|
776
|
+
|
|
777
|
+
try:
|
|
778
|
+
response = await http.get(url, timeout=60)
|
|
779
|
+
response.raise_for_status()
|
|
780
|
+
|
|
781
|
+
with open(file_path, "wb") as f:
|
|
782
|
+
f.write(response.content)
|
|
783
|
+
|
|
784
|
+
saved_files.append(file_path)
|
|
785
|
+
except Exception as e:
|
|
786
|
+
logger.error("Failed to download %s: %s", url, e)
|
|
787
|
+
|
|
788
|
+
return saved_files
|
|
789
|
+
|
|
790
|
+
@run_fn_async(retry=default_retry)
|
|
791
|
+
async def _prepare_and_upload_file(self, presigned_url: Dict, file_path: str) -> str:
|
|
792
|
+
url: str = presigned_url["url"]
|
|
793
|
+
http = await self._get_http()
|
|
794
|
+
|
|
795
|
+
await http.put(
|
|
796
|
+
url,
|
|
797
|
+
content=b"BINARY_DATA",
|
|
798
|
+
headers={"Content-Type": "application/pdf"},
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
logger.debug("Prepared presigned URL (async)")
|
|
802
|
+
logger.debug("Uploading file %s to presigned URL (async)", file_path)
|
|
803
|
+
with open(file_path, "rb") as f:
|
|
804
|
+
content = f.read()
|
|
805
|
+
|
|
806
|
+
resp = await http.put(url, content=content)
|
|
807
|
+
resp.raise_for_status()
|
|
808
|
+
logger.debug("Uploaded file %s to presigned URL (async)", file_path)
|
|
809
|
+
|
|
810
|
+
clean_url = url.split("?")[0]
|
|
811
|
+
return clean_url.split("amazonaws.com/")[-1]
|
|
812
|
+
|
|
813
|
+
@run_fn_async(retry=default_retry)
|
|
814
|
+
async def attach_file_to_card(
|
|
815
|
+
self,
|
|
816
|
+
card_id: str,
|
|
817
|
+
field_id: str,
|
|
818
|
+
file_paths: List[str],
|
|
819
|
+
org_id: str,
|
|
820
|
+
) -> bool:
|
|
821
|
+
parsed_urls: List[str] = []
|
|
822
|
+
for file_path in file_paths:
|
|
823
|
+
presigned_url = await self.presign_url(org_id, file_path)
|
|
824
|
+
|
|
825
|
+
if not presigned_url:
|
|
826
|
+
raise RuntimeError("Failed to get presigned URL from Pipefy")
|
|
827
|
+
|
|
828
|
+
parsed_url = await self._prepare_and_upload_file(presigned_url, file_path)
|
|
829
|
+
parsed_urls.append(parsed_url)
|
|
830
|
+
|
|
831
|
+
result = await self.update_card_field_by_id(card_id=card_id, field_id=field_id, new_value=parsed_urls)
|
|
832
|
+
|
|
833
|
+
if result:
|
|
834
|
+
logger.info(
|
|
835
|
+
"Updated card %s field %s with uploaded file (async).",
|
|
836
|
+
card_id,
|
|
837
|
+
field_id,
|
|
838
|
+
)
|
|
839
|
+
else:
|
|
840
|
+
logger.error(
|
|
841
|
+
"Failed to update card %s field %s with uploaded file (async).",
|
|
842
|
+
card_id,
|
|
843
|
+
field_id,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
return result
|
|
847
|
+
|
|
848
|
+
@run_fn_async(retry=default_retry)
|
|
849
|
+
async def update_card_labels(
|
|
850
|
+
self,
|
|
851
|
+
card_id: str,
|
|
852
|
+
label_ids: list[str],
|
|
853
|
+
) -> bool:
|
|
854
|
+
mutation = """
|
|
855
|
+
mutation UpdateCard($input: UpdateCardInput!) {
|
|
856
|
+
updateCard(input: $input) {
|
|
857
|
+
card {
|
|
858
|
+
id
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
"""
|
|
863
|
+
|
|
864
|
+
variables = {
|
|
865
|
+
"input": {
|
|
866
|
+
"id": card_id,
|
|
867
|
+
"label_ids": label_ids,
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
response = await self._graphql(mutation, variables)
|
|
872
|
+
|
|
873
|
+
if not response:
|
|
874
|
+
return False
|
|
875
|
+
|
|
876
|
+
if "errors" in response:
|
|
877
|
+
raise Exception(f"Pipefy error: {response['errors']}")
|
|
878
|
+
|
|
879
|
+
return bool((response.get("updateCard") or {}).get("card"))
|
|
880
|
+
|
|
881
|
+
@run_fn_async(retry=default_retry)
|
|
882
|
+
async def add_label_to_card(
|
|
883
|
+
self,
|
|
884
|
+
card_id: str,
|
|
885
|
+
new_label_id: str,
|
|
886
|
+
) -> bool:
|
|
887
|
+
query = """
|
|
888
|
+
query GetCardLabels($id: ID!) {
|
|
889
|
+
card(id: $id) {
|
|
890
|
+
labels {
|
|
891
|
+
id
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
data = await self._graphql(query, {"id": card_id})
|
|
898
|
+
|
|
899
|
+
if not data or "errors" in data:
|
|
900
|
+
raise Exception(f"Pipefy error: {data.get('errors')}")
|
|
901
|
+
|
|
902
|
+
current_labels = [label["id"] for label in (data.get("card") or {}).get("labels", [])]
|
|
903
|
+
|
|
904
|
+
if new_label_id not in current_labels:
|
|
905
|
+
current_labels.append(new_label_id)
|
|
906
|
+
|
|
907
|
+
return await self.update_card_labels(card_id, current_labels)
|