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,1016 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from ergon.task.helpers import run_fn
|
|
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 PipefyService:
|
|
24
|
+
def __init__(self, client: PipefyClient):
|
|
25
|
+
logger.info("Initializing PipefyService")
|
|
26
|
+
|
|
27
|
+
self.client = client
|
|
28
|
+
self.endpoint = client.endpoint
|
|
29
|
+
self.timeout_sec = client.timeout_sec
|
|
30
|
+
self.session = requests.Session()
|
|
31
|
+
|
|
32
|
+
self._after_cursor = None
|
|
33
|
+
self._has_next_page = False
|
|
34
|
+
|
|
35
|
+
self.authenticate()
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------
|
|
38
|
+
# AUTHENTICATION
|
|
39
|
+
# ---------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def get_access_token(self) -> str | None:
|
|
42
|
+
return self._token
|
|
43
|
+
|
|
44
|
+
@run_fn(retry=default_retry, trace_name="PipefyService.authenticate")
|
|
45
|
+
def authenticate(self) -> None:
|
|
46
|
+
logger.info("Authenticating with Pipefy")
|
|
47
|
+
resp = requests.post(
|
|
48
|
+
self.client.oauth_token_url,
|
|
49
|
+
json={
|
|
50
|
+
"grant_type": "client_credentials",
|
|
51
|
+
"client_id": self.client.client_id,
|
|
52
|
+
"client_secret": self.client.client_secret,
|
|
53
|
+
},
|
|
54
|
+
timeout=30,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
data = resp.json()
|
|
59
|
+
self._token = data.get("access_token")
|
|
60
|
+
|
|
61
|
+
logger.info("Authenticated successfully")
|
|
62
|
+
|
|
63
|
+
self.session.headers.update(
|
|
64
|
+
{
|
|
65
|
+
"Authorization": f"Bearer {self._token}",
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------
|
|
71
|
+
# GENERIC GRAPHQL
|
|
72
|
+
# ---------------------------------------------------------
|
|
73
|
+
def _graphql(self, query: str, variables: Dict[str, Any]) -> Dict[str, Any]:
|
|
74
|
+
if not self._token:
|
|
75
|
+
raise ValueError("Authentication required")
|
|
76
|
+
|
|
77
|
+
payload = {"query": query, "variables": variables}
|
|
78
|
+
|
|
79
|
+
def send_request():
|
|
80
|
+
return self.session.post(
|
|
81
|
+
self.endpoint,
|
|
82
|
+
json=payload,
|
|
83
|
+
timeout=self.timeout_sec,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
resp = send_request()
|
|
87
|
+
|
|
88
|
+
logger.debug(f"GraphQL query response: {resp.status_code}")
|
|
89
|
+
|
|
90
|
+
# Re-auth
|
|
91
|
+
if resp.status_code == 401:
|
|
92
|
+
logger.debug("Unauthorized. Re-authenticating with Pipefy")
|
|
93
|
+
self.authenticate()
|
|
94
|
+
logger.debug("Re-authenticated successfully")
|
|
95
|
+
resp = send_request()
|
|
96
|
+
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
data = resp.json()
|
|
99
|
+
|
|
100
|
+
logger.debug("GraphQL query completed successfully")
|
|
101
|
+
data = resp.json()
|
|
102
|
+
return data.get("data", {})
|
|
103
|
+
|
|
104
|
+
@run_fn(retry=default_retry)
|
|
105
|
+
def get_pipe_fields(
|
|
106
|
+
self,
|
|
107
|
+
pipe_id: str,
|
|
108
|
+
response_fields: Optional[str] = None,
|
|
109
|
+
) -> List[Dict[str, Any]]:
|
|
110
|
+
"""
|
|
111
|
+
Retrieve ALL fields from ALL phases of a Pipefy pipe.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
pipe_id: Pipe ID
|
|
115
|
+
response_fields: Optional GraphQL shape override
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Flat list of fields in the format:
|
|
119
|
+
[
|
|
120
|
+
{
|
|
121
|
+
"phase_id": "123",
|
|
122
|
+
"phase_name": "Formulário Inicial",
|
|
123
|
+
"id": "cpf",
|
|
124
|
+
"label": "CPF"
|
|
125
|
+
},
|
|
126
|
+
...
|
|
127
|
+
]
|
|
128
|
+
"""
|
|
129
|
+
if not pipe_id:
|
|
130
|
+
raise RuntimeError("pipe_id must be provided")
|
|
131
|
+
|
|
132
|
+
# Default GraphQL response shape
|
|
133
|
+
if response_fields is None:
|
|
134
|
+
response_fields = """
|
|
135
|
+
id
|
|
136
|
+
name
|
|
137
|
+
phases {
|
|
138
|
+
id
|
|
139
|
+
name
|
|
140
|
+
fields {
|
|
141
|
+
id
|
|
142
|
+
label
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
query = f"""
|
|
148
|
+
query GetPipeFields($pipeId: ID!) {{
|
|
149
|
+
pipe(id: $pipeId) {{
|
|
150
|
+
{response_fields}
|
|
151
|
+
}}
|
|
152
|
+
}}
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
data = self._graphql(query, {"pipeId": pipe_id})
|
|
156
|
+
|
|
157
|
+
pipe = data.get("pipe") or {}
|
|
158
|
+
phases = pipe.get("phases") or []
|
|
159
|
+
|
|
160
|
+
# Log useful info
|
|
161
|
+
total_fields = sum(len(p.get("fields", [])) for p in phases)
|
|
162
|
+
logger.info(
|
|
163
|
+
"Retrieved %d phases and %d total fields from pipe %s",
|
|
164
|
+
len(phases),
|
|
165
|
+
total_fields,
|
|
166
|
+
pipe_id,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Flatten structure
|
|
170
|
+
result = []
|
|
171
|
+
for phase in phases:
|
|
172
|
+
for f in phase.get("fields", []):
|
|
173
|
+
result.append(
|
|
174
|
+
{
|
|
175
|
+
"phase_id": phase.get("id"),
|
|
176
|
+
"phase_name": phase.get("name"),
|
|
177
|
+
"id": f.get("id"),
|
|
178
|
+
"label": f.get("label"),
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
# -------------------------------------------------------------
|
|
185
|
+
@run_fn(retry=default_retry)
|
|
186
|
+
def get_pipe_start_form_fields(
|
|
187
|
+
self,
|
|
188
|
+
pipe_id: str,
|
|
189
|
+
response_fields: Optional[str] = None,
|
|
190
|
+
) -> List[Dict[str, Any]]:
|
|
191
|
+
"""
|
|
192
|
+
Retrieve the START FORM fields of a Pipefy pipe.
|
|
193
|
+
These are the fields shown to the user when creating a card.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
pipe_id: Pipe ID
|
|
197
|
+
response_fields: Optional GraphQL shape override
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
[
|
|
201
|
+
{
|
|
202
|
+
"phase_id": "<pipe_id>",
|
|
203
|
+
"phase_name": "<pipe_name>",
|
|
204
|
+
"id": "field_id",
|
|
205
|
+
"label": "Field Label"
|
|
206
|
+
},
|
|
207
|
+
...
|
|
208
|
+
]
|
|
209
|
+
"""
|
|
210
|
+
if not pipe_id:
|
|
211
|
+
raise RuntimeError("pipe_id must be provided")
|
|
212
|
+
|
|
213
|
+
if response_fields is None:
|
|
214
|
+
response_fields = """
|
|
215
|
+
id
|
|
216
|
+
name
|
|
217
|
+
start_form_fields {
|
|
218
|
+
id
|
|
219
|
+
label
|
|
220
|
+
}
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
query = f"""
|
|
224
|
+
query GetStartFormFields($pipeId: ID!) {{
|
|
225
|
+
pipe(id: $pipeId) {{
|
|
226
|
+
{response_fields}
|
|
227
|
+
}}
|
|
228
|
+
}}
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
data = self._graphql(query, {"pipeId": pipe_id})
|
|
232
|
+
|
|
233
|
+
pipe = data.get("pipe") or {}
|
|
234
|
+
fields = pipe.get("start_form_fields") or []
|
|
235
|
+
|
|
236
|
+
result = [
|
|
237
|
+
{
|
|
238
|
+
"phase_id": pipe.get("id"),
|
|
239
|
+
"phase_name": pipe.get("name"),
|
|
240
|
+
"id": f.get("id"),
|
|
241
|
+
"label": f.get("label"),
|
|
242
|
+
}
|
|
243
|
+
for f in fields
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
logger.info(
|
|
247
|
+
"Retrieved %d start_form_fields from pipe %s (%s)",
|
|
248
|
+
len(fields),
|
|
249
|
+
pipe_id,
|
|
250
|
+
pipe.get("name"),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------
|
|
256
|
+
# CARD QUERIES
|
|
257
|
+
# ---------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
@run_fn(retry=default_retry)
|
|
260
|
+
def get_card_by_id(
|
|
261
|
+
self,
|
|
262
|
+
card_id: str,
|
|
263
|
+
response_fields: Optional[str] = None,
|
|
264
|
+
) -> Dict:
|
|
265
|
+
if response_fields is None:
|
|
266
|
+
response_fields = """
|
|
267
|
+
id
|
|
268
|
+
title
|
|
269
|
+
fields {
|
|
270
|
+
field { id }
|
|
271
|
+
name
|
|
272
|
+
value
|
|
273
|
+
}
|
|
274
|
+
current_phase { id name }
|
|
275
|
+
updated_at
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
query = f"""
|
|
279
|
+
query GetCard($id: ID!) {{
|
|
280
|
+
card(id: $id) {{
|
|
281
|
+
{response_fields}
|
|
282
|
+
}}
|
|
283
|
+
}}
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
data = self._graphql(query, {"id": card_id})
|
|
287
|
+
return data.get("card", {})
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
@run_fn(retry=default_retry)
|
|
292
|
+
def get_next_card(
|
|
293
|
+
self,
|
|
294
|
+
phase_id: str,
|
|
295
|
+
field_filters: Optional[List[FieldFilter]] = None,
|
|
296
|
+
batch_size: int = 1,
|
|
297
|
+
response_fields: Optional[str] = None,
|
|
298
|
+
) -> Optional[List[Dict]]:
|
|
299
|
+
if response_fields is None:
|
|
300
|
+
response_fields = """
|
|
301
|
+
id
|
|
302
|
+
title
|
|
303
|
+
fields {
|
|
304
|
+
field { id }
|
|
305
|
+
name
|
|
306
|
+
value
|
|
307
|
+
}
|
|
308
|
+
current_phase { id name }
|
|
309
|
+
updated_at
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
query = f"""
|
|
313
|
+
query GetCardsFromPhase($phaseId: ID!, $after: String, $batch_size: Int!) {{
|
|
314
|
+
phase(id: $phaseId) {{
|
|
315
|
+
id
|
|
316
|
+
name
|
|
317
|
+
cards(first: $batch_size, after: $after) {{
|
|
318
|
+
edges {{
|
|
319
|
+
node {{
|
|
320
|
+
{response_fields}
|
|
321
|
+
}}
|
|
322
|
+
}}
|
|
323
|
+
pageInfo {{
|
|
324
|
+
hasNextPage
|
|
325
|
+
endCursor
|
|
326
|
+
}}
|
|
327
|
+
}}
|
|
328
|
+
}}
|
|
329
|
+
}}
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
data = self._graphql(
|
|
333
|
+
query,
|
|
334
|
+
{"phaseId": phase_id, "after": self._after_cursor, "batch_size": batch_size},
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
phase = data.get("phase", {})
|
|
338
|
+
if not phase:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
cards = phase.get("cards", {}).get("edges", [])
|
|
342
|
+
if not cards:
|
|
343
|
+
self._after_cursor = None
|
|
344
|
+
self._has_next_page = False
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
page_info = phase.get("cards", {}).get("pageInfo", {})
|
|
348
|
+
self._after_cursor = page_info.get("endCursor")
|
|
349
|
+
self._has_next_page = page_info.get("hasNextPage")
|
|
350
|
+
|
|
351
|
+
filtered = []
|
|
352
|
+
for edge in cards:
|
|
353
|
+
card = edge.get("node", {})
|
|
354
|
+
if self.__apply_client_side_filter(card, field_filters):
|
|
355
|
+
filtered.append(card)
|
|
356
|
+
|
|
357
|
+
return filtered or None
|
|
358
|
+
|
|
359
|
+
@run_fn(retry=default_retry)
|
|
360
|
+
def search_cards_by_field(
|
|
361
|
+
self,
|
|
362
|
+
pipe_id: str,
|
|
363
|
+
field_id: str,
|
|
364
|
+
field_value: str,
|
|
365
|
+
response_fields: Optional[str] = None,
|
|
366
|
+
) -> List[Dict]:
|
|
367
|
+
"""
|
|
368
|
+
Find cards in a Pipefy pipe by searching a **single custom field value**.
|
|
369
|
+
Uses Pipefy's `findCards` GraphQL API.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
pipe_id: Target pipe
|
|
373
|
+
field_id: The Pipefy field ID / indexName used for the search
|
|
374
|
+
field_value: Value to match exactly
|
|
375
|
+
response_fields: Optional override for GraphQL returned fields
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
A list of card dicts (flattened): [{...}, {...}]
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
if not pipe_id:
|
|
382
|
+
raise RuntimeError("pipe_id must be provided")
|
|
383
|
+
if not field_id:
|
|
384
|
+
raise RuntimeError("field_id must be provided")
|
|
385
|
+
|
|
386
|
+
# Default GraphQL shape (consistent with other manager methods)
|
|
387
|
+
if response_fields is None:
|
|
388
|
+
response_fields = """
|
|
389
|
+
id
|
|
390
|
+
title
|
|
391
|
+
updated_at
|
|
392
|
+
current_phase { id name }
|
|
393
|
+
fields {
|
|
394
|
+
field { id }
|
|
395
|
+
name
|
|
396
|
+
value
|
|
397
|
+
}
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
query = f"""
|
|
401
|
+
query FindCards($pipeId: ID!, $fieldId: String!, $fieldValue: String!) {{
|
|
402
|
+
findCards(
|
|
403
|
+
pipeId: $pipeId,
|
|
404
|
+
search: {{ fieldId: $fieldId, fieldValue: $fieldValue }}
|
|
405
|
+
) {{
|
|
406
|
+
edges {{
|
|
407
|
+
node {{
|
|
408
|
+
{response_fields}
|
|
409
|
+
}}
|
|
410
|
+
}}
|
|
411
|
+
}}
|
|
412
|
+
}}
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
variables = {
|
|
416
|
+
"pipeId": str(pipe_id),
|
|
417
|
+
"fieldId": field_id,
|
|
418
|
+
"fieldValue": field_value,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
data = self._graphql(query, variables)
|
|
422
|
+
edges = ((data.get("findCards") or {}).get("edges")) or []
|
|
423
|
+
|
|
424
|
+
cards = [edge.get("node", {}) for edge in edges]
|
|
425
|
+
|
|
426
|
+
logger.info(
|
|
427
|
+
"Found %d cards in pipe %s where field %s == %s",
|
|
428
|
+
len(cards),
|
|
429
|
+
pipe_id,
|
|
430
|
+
field_id,
|
|
431
|
+
field_value,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
return cards
|
|
435
|
+
|
|
436
|
+
# ---------------------------------------------------------
|
|
437
|
+
# CREATE CARD
|
|
438
|
+
# ---------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
@run_fn(retry=default_retry)
|
|
441
|
+
def create_card(
|
|
442
|
+
self,
|
|
443
|
+
card: CreateCardInput,
|
|
444
|
+
response_fields: Optional[str] = None,
|
|
445
|
+
) -> Dict:
|
|
446
|
+
if response_fields is None:
|
|
447
|
+
response_fields = """
|
|
448
|
+
id
|
|
449
|
+
title
|
|
450
|
+
current_phase { id name }
|
|
451
|
+
fields { name value field { id type } }
|
|
452
|
+
created_at
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
mutation = f"""
|
|
456
|
+
mutation CreateCard($input: CreateCardInput!) {{
|
|
457
|
+
createCard(input: $input) {{
|
|
458
|
+
card {{
|
|
459
|
+
{response_fields}
|
|
460
|
+
}}
|
|
461
|
+
}}
|
|
462
|
+
}}
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
variables = {"input": card.model_dump()}
|
|
466
|
+
data = self._graphql(mutation, variables)
|
|
467
|
+
|
|
468
|
+
return (data.get("createCard") or {}).get("card") or {}
|
|
469
|
+
|
|
470
|
+
# ---------------------------------------------------------
|
|
471
|
+
# FIELD HELPERS
|
|
472
|
+
# ---------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
def get_field_value_by_name(self, card: Dict, field_name: str) -> Any:
|
|
475
|
+
for field in card.get("fields", []):
|
|
476
|
+
if field.get("name") == field_name:
|
|
477
|
+
try:
|
|
478
|
+
is_list = json.loads(field.get("value"))
|
|
479
|
+
if isinstance(is_list, list):
|
|
480
|
+
if is_list:
|
|
481
|
+
return is_list
|
|
482
|
+
else:
|
|
483
|
+
return None
|
|
484
|
+
else:
|
|
485
|
+
return field.get("value")
|
|
486
|
+
except Exception:
|
|
487
|
+
return field.get("value")
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
def get_field_value_by_id(self, card: Dict, field_id: str) -> Any:
|
|
491
|
+
for field in card.get("fields", []):
|
|
492
|
+
if field.get("field", {}).get("id", {}) == field_id:
|
|
493
|
+
try:
|
|
494
|
+
is_list = json.loads(field.get("value"))
|
|
495
|
+
if isinstance(is_list, list):
|
|
496
|
+
if is_list:
|
|
497
|
+
return is_list
|
|
498
|
+
else:
|
|
499
|
+
return None
|
|
500
|
+
else:
|
|
501
|
+
return field.get("value")
|
|
502
|
+
except Exception:
|
|
503
|
+
return field.get("value")
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
# ---------------------------------------------------------
|
|
507
|
+
# CARD OPERATIONS
|
|
508
|
+
# ---------------------------------------------------------
|
|
509
|
+
@run_fn(retry=default_retry)
|
|
510
|
+
def move_card_to_phase(
|
|
511
|
+
self,
|
|
512
|
+
card_id: str,
|
|
513
|
+
phase_id: str,
|
|
514
|
+
response_fields: str | None = None,
|
|
515
|
+
):
|
|
516
|
+
# Default fields returned by the mutation
|
|
517
|
+
if not response_fields:
|
|
518
|
+
response_fields = """
|
|
519
|
+
card {
|
|
520
|
+
id
|
|
521
|
+
title
|
|
522
|
+
current_phase {
|
|
523
|
+
id
|
|
524
|
+
name
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
"""
|
|
528
|
+
|
|
529
|
+
mutation = f"""
|
|
530
|
+
mutation MoveCardToPhase($input: MoveCardToPhaseInput!) {{
|
|
531
|
+
moveCardToPhase(input: $input) {{
|
|
532
|
+
{response_fields}
|
|
533
|
+
}}
|
|
534
|
+
}}
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
variables = {"input": {"card_id": card_id, "destination_phase_id": phase_id}}
|
|
538
|
+
|
|
539
|
+
data = self._graphql(mutation, variables)
|
|
540
|
+
result = data.get("moveCardToPhase")
|
|
541
|
+
|
|
542
|
+
# If result exists, the mutation succeeded
|
|
543
|
+
return result is not None
|
|
544
|
+
|
|
545
|
+
# ---------------------------------------------------------
|
|
546
|
+
@run_fn(retry=default_retry)
|
|
547
|
+
def update_card_fields_by_id(
|
|
548
|
+
self,
|
|
549
|
+
card_id: str,
|
|
550
|
+
fields: Dict[str, Any],
|
|
551
|
+
response_fields: Optional[str] = None,
|
|
552
|
+
) -> Dict:
|
|
553
|
+
if not response_fields:
|
|
554
|
+
response_fields = """
|
|
555
|
+
... on Card {
|
|
556
|
+
id
|
|
557
|
+
title
|
|
558
|
+
fields { name value }
|
|
559
|
+
current_phase { id name }
|
|
560
|
+
}
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
# 1. Note the generic 'nodeId' and 'values' in the mutation signature
|
|
564
|
+
mutation = f"""
|
|
565
|
+
mutation UpdateFieldsValues($input: UpdateFieldsValuesInput!) {{
|
|
566
|
+
updateFieldsValues(input: $input) {{
|
|
567
|
+
success
|
|
568
|
+
updatedNode {{
|
|
569
|
+
{response_fields}
|
|
570
|
+
}}
|
|
571
|
+
userErrors {{
|
|
572
|
+
field
|
|
573
|
+
message
|
|
574
|
+
}}
|
|
575
|
+
}}
|
|
576
|
+
}}
|
|
577
|
+
"""
|
|
578
|
+
|
|
579
|
+
# 2. Fix the item structure: 'fieldId' and 'value'
|
|
580
|
+
#
|
|
581
|
+
field_attrs = [{"fieldId": fid, "value": value} for fid, value in fields.items()]
|
|
582
|
+
|
|
583
|
+
# 3. Fix the input structure: 'nodeId' and 'values'
|
|
584
|
+
#
|
|
585
|
+
variables = {
|
|
586
|
+
"input": {
|
|
587
|
+
"nodeId": card_id, # CHANGED: card_id -> nodeId
|
|
588
|
+
"values": field_attrs, # CHANGED: fields_attributes -> values
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
data = self._graphql(mutation, variables)
|
|
593
|
+
result = data.get("updateFieldsValues") or {}
|
|
594
|
+
|
|
595
|
+
if result.get("userErrors"):
|
|
596
|
+
raise RuntimeError(f"Pipefy update failed: {result['userErrors']}")
|
|
597
|
+
|
|
598
|
+
return result.get("updatedNode") or {}
|
|
599
|
+
|
|
600
|
+
@run_fn(retry=default_retry)
|
|
601
|
+
def update_card_field_by_id(
|
|
602
|
+
self,
|
|
603
|
+
card_id: str,
|
|
604
|
+
field_id: str,
|
|
605
|
+
new_value: str,
|
|
606
|
+
) -> bool:
|
|
607
|
+
mutation = """
|
|
608
|
+
mutation UpdateCardField($input: UpdateCardFieldInput!) {
|
|
609
|
+
updateCardField(input: $input) {
|
|
610
|
+
success
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
variables = {
|
|
616
|
+
"input": {
|
|
617
|
+
"card_id": card_id,
|
|
618
|
+
"field_id": field_id,
|
|
619
|
+
"new_value": new_value,
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
data = self._graphql(mutation, variables)
|
|
624
|
+
return (data.get("updateCardField") or {}).get("success", False)
|
|
625
|
+
|
|
626
|
+
# ---------------------------------------------------------
|
|
627
|
+
# FILTERS
|
|
628
|
+
# ---------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
def __apply_client_side_filter(
|
|
631
|
+
self,
|
|
632
|
+
card: Dict,
|
|
633
|
+
field_filters: Optional[List[FieldFilter]],
|
|
634
|
+
) -> Optional[Dict]:
|
|
635
|
+
if not field_filters:
|
|
636
|
+
return card
|
|
637
|
+
|
|
638
|
+
card_fields = card.get("fields", [])
|
|
639
|
+
|
|
640
|
+
for filter in field_filters:
|
|
641
|
+
value = next(
|
|
642
|
+
(f.get("value") for f in card_fields if f.get("name") == filter.field),
|
|
643
|
+
None,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
if value is None:
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
if filter.operator == FieldFilterOperator.EQUAL:
|
|
650
|
+
if value != filter.value:
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
return card
|
|
654
|
+
|
|
655
|
+
@run_fn(retry=default_retry)
|
|
656
|
+
def presign_url(
|
|
657
|
+
self,
|
|
658
|
+
org_id: str,
|
|
659
|
+
file_path: str,
|
|
660
|
+
response_fields: Optional[str] = None,
|
|
661
|
+
) -> Dict:
|
|
662
|
+
"""
|
|
663
|
+
Request a pre-signed upload URL from Pipefy for uploading attachments.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
org_id: Organization ID
|
|
667
|
+
file_path: Local file path
|
|
668
|
+
response_fields: Optional override for returned GraphQL fields
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
Dict containing the presigned URL metadata returned by Pipefy.
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
if not org_id:
|
|
675
|
+
raise RuntimeError("org_id must be provided")
|
|
676
|
+
|
|
677
|
+
if not file_path:
|
|
678
|
+
raise RuntimeError("file_path must be provided")
|
|
679
|
+
|
|
680
|
+
if response_fields is None:
|
|
681
|
+
# Pipefy only returns 'url' for this mutation, but we support extension
|
|
682
|
+
response_fields = "url"
|
|
683
|
+
|
|
684
|
+
mutation = f"""
|
|
685
|
+
mutation CreatePresignedUrl($input: CreatePresignedUrlInput!) {{
|
|
686
|
+
createPresignedUrl(input: $input) {{
|
|
687
|
+
{response_fields}
|
|
688
|
+
}}
|
|
689
|
+
}}
|
|
690
|
+
"""
|
|
691
|
+
|
|
692
|
+
variables = {
|
|
693
|
+
"input": {
|
|
694
|
+
"organizationId": org_id,
|
|
695
|
+
"fileName": os.path.basename(str(file_path)),
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
data = self._graphql(mutation, variables)
|
|
700
|
+
result = data.get("createPresignedUrl") or {}
|
|
701
|
+
|
|
702
|
+
logger.debug(
|
|
703
|
+
"Received presigned URL for '%s' in org '%s': %s",
|
|
704
|
+
os.path.basename(file_path),
|
|
705
|
+
org_id,
|
|
706
|
+
result.get("url"),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
return result
|
|
710
|
+
|
|
711
|
+
@run_fn(retry=default_retry)
|
|
712
|
+
def upload_file(
|
|
713
|
+
self,
|
|
714
|
+
presigned_url: Dict,
|
|
715
|
+
file_path: str,
|
|
716
|
+
content_type: str = "application/octet-stream",
|
|
717
|
+
) -> str:
|
|
718
|
+
"""
|
|
719
|
+
Upload a local file to the provided Pipefy pre-signed URL.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
presigned_url: Dict returned from create_presigned_url()
|
|
723
|
+
file_path: Path to the local file
|
|
724
|
+
content_type: MIME type for upload (default generic binary)
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
The S3 object key portion of the URL (without query params).
|
|
728
|
+
"""
|
|
729
|
+
|
|
730
|
+
url = presigned_url.get("url")
|
|
731
|
+
if not url:
|
|
732
|
+
raise RuntimeError("Invalid presigned_url: missing 'url'")
|
|
733
|
+
|
|
734
|
+
if not file_path:
|
|
735
|
+
raise RuntimeError("file_path must be provided")
|
|
736
|
+
|
|
737
|
+
logger.debug("Uploading file '%s' → %s", file_path, url)
|
|
738
|
+
|
|
739
|
+
with open(file_path, "rb") as f:
|
|
740
|
+
resp = requests.put(
|
|
741
|
+
url,
|
|
742
|
+
data=f,
|
|
743
|
+
headers={"Content-Type": content_type},
|
|
744
|
+
timeout=60,
|
|
745
|
+
)
|
|
746
|
+
resp.raise_for_status()
|
|
747
|
+
|
|
748
|
+
logger.info("Successfully uploaded file '%s'.", file_path)
|
|
749
|
+
|
|
750
|
+
clean_url = url.split("?")[0]
|
|
751
|
+
s3_key = clean_url.split("amazonaws.com/")[-1]
|
|
752
|
+
|
|
753
|
+
logger.debug("Resolved S3 key after upload: %s", s3_key)
|
|
754
|
+
|
|
755
|
+
return s3_key
|
|
756
|
+
|
|
757
|
+
@run_fn(retry=default_retry)
|
|
758
|
+
def get_database_record_by_title(
|
|
759
|
+
self,
|
|
760
|
+
database_id: str,
|
|
761
|
+
title: str,
|
|
762
|
+
limit: int = 100,
|
|
763
|
+
response_fields: Optional[str] = None,
|
|
764
|
+
) -> List[Dict]:
|
|
765
|
+
"""
|
|
766
|
+
Search Pipefy Database Records by title.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
database_id: Pipefy database ID
|
|
770
|
+
title: search text for the record title
|
|
771
|
+
limit: maximum records to return
|
|
772
|
+
response_fields: optional custom GraphQL structure override
|
|
773
|
+
|
|
774
|
+
Returns:
|
|
775
|
+
List of record nodes (flat dictionaries)
|
|
776
|
+
"""
|
|
777
|
+
|
|
778
|
+
if response_fields is None:
|
|
779
|
+
response_fields = """
|
|
780
|
+
id
|
|
781
|
+
title
|
|
782
|
+
record_fields {
|
|
783
|
+
name
|
|
784
|
+
value
|
|
785
|
+
field { id }
|
|
786
|
+
}
|
|
787
|
+
"""
|
|
788
|
+
|
|
789
|
+
query = f"""
|
|
790
|
+
query GetDatabaseRecords($databaseId: ID!, $first: Int!, $title: String!) {{
|
|
791
|
+
table_records(
|
|
792
|
+
table_id: $databaseId,
|
|
793
|
+
first: $first,
|
|
794
|
+
search: {{ title: $title }}
|
|
795
|
+
) {{
|
|
796
|
+
edges {{
|
|
797
|
+
node {{
|
|
798
|
+
{response_fields}
|
|
799
|
+
}}
|
|
800
|
+
}}
|
|
801
|
+
}}
|
|
802
|
+
}}
|
|
803
|
+
"""
|
|
804
|
+
|
|
805
|
+
variables = {
|
|
806
|
+
"databaseId": str(database_id),
|
|
807
|
+
"first": limit,
|
|
808
|
+
"title": title,
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
data = self._graphql(query, variables)
|
|
813
|
+
|
|
814
|
+
edges = (data.get("table_records") or {}).get("edges", [])
|
|
815
|
+
records = [edge.get("node", {}) for edge in edges]
|
|
816
|
+
|
|
817
|
+
logger.info(
|
|
818
|
+
"Retrieved %d database records from database %s matching title '%s'",
|
|
819
|
+
len(records),
|
|
820
|
+
database_id,
|
|
821
|
+
title,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
return records
|
|
825
|
+
|
|
826
|
+
except Exception as e:
|
|
827
|
+
logger.error(
|
|
828
|
+
f"Error fetching database records for database {database_id} and title '{title}': {e}",
|
|
829
|
+
exc_info=True,
|
|
830
|
+
)
|
|
831
|
+
return []
|
|
832
|
+
|
|
833
|
+
@run_fn(retry=default_retry)
|
|
834
|
+
def download_card_attachments(
|
|
835
|
+
self,
|
|
836
|
+
field_id: str,
|
|
837
|
+
card_id: Optional[str] = None,
|
|
838
|
+
card: Optional[dict] = None,
|
|
839
|
+
output_dir: str = "attachments",
|
|
840
|
+
) -> List[str]:
|
|
841
|
+
"""
|
|
842
|
+
Download card attachments from a specific field.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
card_id: The ID of the card.
|
|
846
|
+
field_id: The ID of the attachment field.
|
|
847
|
+
output_dir: Directory to save downloaded files.
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
List[str]: Paths to the downloaded files.
|
|
851
|
+
"""
|
|
852
|
+
|
|
853
|
+
if not card and not card_id:
|
|
854
|
+
raise Exception("Either card or card_id must be passed as parameter")
|
|
855
|
+
|
|
856
|
+
if card_id:
|
|
857
|
+
card = self.get_card_by_id(card_id)
|
|
858
|
+
|
|
859
|
+
if not card:
|
|
860
|
+
raise Exception("Could not resolve card")
|
|
861
|
+
|
|
862
|
+
attachments = self.get_field_value_by_id(card, field_id)
|
|
863
|
+
|
|
864
|
+
if not attachments:
|
|
865
|
+
logger.warning("No attachments found for field '%s' in card %s", field_id, card_id)
|
|
866
|
+
return []
|
|
867
|
+
|
|
868
|
+
if isinstance(attachments, str):
|
|
869
|
+
attachments = [attachments]
|
|
870
|
+
elif not isinstance(attachments, list):
|
|
871
|
+
logger.error("Unexpected attachments format: %s", type(attachments))
|
|
872
|
+
return []
|
|
873
|
+
|
|
874
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
875
|
+
saved_files = []
|
|
876
|
+
|
|
877
|
+
for i, url in enumerate(attachments):
|
|
878
|
+
# Clean URL to get filename
|
|
879
|
+
# Example: .../uuid/filename.ext?params
|
|
880
|
+
clean_url = url.split("?")[0]
|
|
881
|
+
original_filename = clean_url.split("/")[-1]
|
|
882
|
+
|
|
883
|
+
# Index the filename to avoid collisions and preserve order
|
|
884
|
+
filename = f"{i}_{original_filename}"
|
|
885
|
+
file_path = os.path.join(output_dir, filename)
|
|
886
|
+
|
|
887
|
+
logger.info("Downloading attachment %d to %s", i, file_path)
|
|
888
|
+
|
|
889
|
+
try:
|
|
890
|
+
response = requests.get(url, timeout=60)
|
|
891
|
+
response.raise_for_status()
|
|
892
|
+
|
|
893
|
+
with open(file_path, "wb") as f:
|
|
894
|
+
f.write(response.content)
|
|
895
|
+
|
|
896
|
+
saved_files.append(file_path)
|
|
897
|
+
except Exception as e:
|
|
898
|
+
logger.error("Failed to download %s: %s", url, e)
|
|
899
|
+
|
|
900
|
+
return saved_files
|
|
901
|
+
|
|
902
|
+
@run_fn(retry=default_retry)
|
|
903
|
+
def _prepare_and_upload_file(self, presigned_url: Dict, file_path: str) -> str:
|
|
904
|
+
url: str = presigned_url["url"]
|
|
905
|
+
|
|
906
|
+
requests.put(
|
|
907
|
+
url,
|
|
908
|
+
data="BINARY_DATA",
|
|
909
|
+
headers={"Content-Type": "application/pdf"},
|
|
910
|
+
).raise_for_status()
|
|
911
|
+
|
|
912
|
+
logger.debug("Prepared presigned URL")
|
|
913
|
+
logger.debug("Uploading file %s to presigned URL", file_path)
|
|
914
|
+
with open(file_path, "rb") as f:
|
|
915
|
+
resp = requests.put(url, data=f)
|
|
916
|
+
resp.raise_for_status()
|
|
917
|
+
logger.debug("Uploaded file %s to presigned URL", file_path)
|
|
918
|
+
|
|
919
|
+
clean_url = url.split("?")[0]
|
|
920
|
+
return clean_url.split("amazonaws.com/")[-1]
|
|
921
|
+
|
|
922
|
+
@run_fn(retry=default_retry)
|
|
923
|
+
def attach_file_to_card(
|
|
924
|
+
self,
|
|
925
|
+
card_id: str,
|
|
926
|
+
field_id: str,
|
|
927
|
+
file_paths: List[str],
|
|
928
|
+
org_id: str,
|
|
929
|
+
) -> bool:
|
|
930
|
+
parsed_urls: List[str] = []
|
|
931
|
+
for file_path in file_paths:
|
|
932
|
+
presigned_url = self.presign_url(org_id, file_path)
|
|
933
|
+
|
|
934
|
+
if not presigned_url:
|
|
935
|
+
raise RuntimeError("Failed to get presigned URL from Pipefy")
|
|
936
|
+
|
|
937
|
+
parsed_url = self._prepare_and_upload_file(presigned_url, file_path)
|
|
938
|
+
parsed_urls.append(parsed_url)
|
|
939
|
+
|
|
940
|
+
result = self.update_card_field_by_id(card_id=card_id, field_id=field_id, new_value=parsed_urls)
|
|
941
|
+
|
|
942
|
+
if result:
|
|
943
|
+
logger.info(
|
|
944
|
+
"Updated card %s field %s with uploaded file.",
|
|
945
|
+
card_id,
|
|
946
|
+
field_id,
|
|
947
|
+
)
|
|
948
|
+
else:
|
|
949
|
+
logger.error(
|
|
950
|
+
"Failed to update card %s field %s with uploaded file.",
|
|
951
|
+
card_id,
|
|
952
|
+
field_id,
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
return result
|
|
956
|
+
|
|
957
|
+
@run_fn(retry=default_retry)
|
|
958
|
+
def update_card_labels(
|
|
959
|
+
self,
|
|
960
|
+
card_id: str,
|
|
961
|
+
label_ids: list[str],
|
|
962
|
+
) -> bool:
|
|
963
|
+
mutation = """
|
|
964
|
+
mutation UpdateCard($input: UpdateCardInput!) {
|
|
965
|
+
updateCard(input: $input) {
|
|
966
|
+
card {
|
|
967
|
+
id
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
"""
|
|
972
|
+
|
|
973
|
+
variables = {
|
|
974
|
+
"input": {
|
|
975
|
+
"id": card_id,
|
|
976
|
+
"label_ids": label_ids,
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
response = self._graphql(mutation, variables)
|
|
981
|
+
|
|
982
|
+
if not response:
|
|
983
|
+
return False
|
|
984
|
+
|
|
985
|
+
if "errors" in response:
|
|
986
|
+
raise Exception(f"Pipefy error: {response['errors']}")
|
|
987
|
+
|
|
988
|
+
return bool((response.get("updateCard") or {}).get("card"))
|
|
989
|
+
|
|
990
|
+
@run_fn(retry=default_retry)
|
|
991
|
+
def add_label_to_card(
|
|
992
|
+
self,
|
|
993
|
+
card_id: str,
|
|
994
|
+
new_label_id: str,
|
|
995
|
+
) -> bool:
|
|
996
|
+
query = """
|
|
997
|
+
query GetCardLabels($id: ID!) {
|
|
998
|
+
card(id: $id) {
|
|
999
|
+
labels {
|
|
1000
|
+
id
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
"""
|
|
1005
|
+
|
|
1006
|
+
data = self._graphql(query, {"id": card_id})
|
|
1007
|
+
|
|
1008
|
+
if not data or "errors" in data:
|
|
1009
|
+
raise Exception(f"Pipefy error: {data.get('errors')}")
|
|
1010
|
+
|
|
1011
|
+
current_labels = [label["id"] for label in (data.get("card") or {}).get("labels", [])]
|
|
1012
|
+
|
|
1013
|
+
if new_label_id not in current_labels:
|
|
1014
|
+
current_labels.append(new_label_id)
|
|
1015
|
+
|
|
1016
|
+
return self.update_card_labels(card_id, current_labels)
|