konecty_sdk_python 2.1.1__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.
Files changed (35) hide show
  1. KonectySdkPython/__init__.py +41 -0
  2. KonectySdkPython/cli/__init__.py +38 -0
  3. KonectySdkPython/cli/apply.py +555 -0
  4. KonectySdkPython/cli/backup.py +161 -0
  5. KonectySdkPython/cli/pull.py +309 -0
  6. KonectySdkPython/lib/__init__.py +43 -0
  7. KonectySdkPython/lib/client.py +950 -0
  8. KonectySdkPython/lib/exceptions.py +26 -0
  9. KonectySdkPython/lib/feature_types/__init__.py +46 -0
  10. KonectySdkPython/lib/feature_types/cross_module_query.py +78 -0
  11. KonectySdkPython/lib/feature_types/kpi.py +21 -0
  12. KonectySdkPython/lib/feature_types/query_json.py +242 -0
  13. KonectySdkPython/lib/file_manager.py +248 -0
  14. KonectySdkPython/lib/filters.py +155 -0
  15. KonectySdkPython/lib/http.py +95 -0
  16. KonectySdkPython/lib/model.py +88 -0
  17. KonectySdkPython/lib/serialization.py +13 -0
  18. KonectySdkPython/lib/services/__init__.py +5 -0
  19. KonectySdkPython/lib/services/aggregation.py +142 -0
  20. KonectySdkPython/lib/services/auth.py +105 -0
  21. KonectySdkPython/lib/services/base.py +63 -0
  22. KonectySdkPython/lib/services/change_user.py +64 -0
  23. KonectySdkPython/lib/services/comments.py +89 -0
  24. KonectySdkPython/lib/services/export.py +54 -0
  25. KonectySdkPython/lib/services/files.py +59 -0
  26. KonectySdkPython/lib/services/notifications.py +41 -0
  27. KonectySdkPython/lib/services/query.py +192 -0
  28. KonectySdkPython/lib/services/stream.py +133 -0
  29. KonectySdkPython/lib/services/subscriptions.py +26 -0
  30. KonectySdkPython/lib/settings.py +152 -0
  31. KonectySdkPython/lib/types.py +418 -0
  32. konecty_sdk_python-2.1.1.dist-info/METADATA +155 -0
  33. konecty_sdk_python-2.1.1.dist-info/RECORD +35 -0
  34. konecty_sdk_python-2.1.1.dist-info/WHEEL +4 -0
  35. konecty_sdk_python-2.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,950 @@
1
+ """Módulo para gerenciar configurações do Konecty."""
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime
6
+ from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Union, cast
7
+
8
+ import aiohttp
9
+
10
+ from .exceptions import KonectyAPIError, KonectyError, KonectyValidationError
11
+ from .feature_types.kpi import KpiConfig
12
+ from .file_manager import FileManager
13
+ from .filters import KonectyFilter, KonectyFindParams
14
+ from .http import StreamResponse
15
+ from .http import request as _http_request
16
+ from .serialization import json_serial
17
+ from .services.aggregation import AggregationService
18
+ from .services.auth import AuthService
19
+ from .services.change_user import ChangeUserService
20
+ from .services.comments import CommentsService
21
+ from .services.export import ExportService
22
+ from .services.files import FilesService
23
+ from .services.notifications import NotificationsService
24
+ from .services.query import QueryResult, QueryService
25
+ from .services.stream import FindStreamResult, StreamService
26
+ from .services.subscriptions import SubscriptionsService
27
+ from .types import KonectyDateTime, KonectyDict, KonectyUpdateId
28
+
29
+ # Configura o logger do urllib3 para mostrar apenas erros
30
+ logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ KONECTY_UPDATE_IGNORE_FIELDS = [
35
+ "_id",
36
+ "code",
37
+ "_updatedAt",
38
+ "_createdAt",
39
+ "_updatedBy",
40
+ "_createdBy",
41
+ ]
42
+ KONECTY_CREATE_IGNORE_FIELDS = ["_updatedAt", "_createdAt", "_updatedBy", "_createdBy"]
43
+
44
+
45
+ def get_first_dict(items: List[Any]) -> Optional[KonectyDict]:
46
+ """Retorna o primeiro item de uma lista como dicionário ou None se estiver vazia."""
47
+ if not items:
48
+ return None
49
+ first = items[0]
50
+ if isinstance(first, dict):
51
+ return cast(KonectyDict, first)
52
+ return None
53
+
54
+
55
+ class KonectyClient:
56
+ def __init__(self, base_url: str, token: str = "") -> None:
57
+ self.base_url = base_url
58
+ self.headers = {"Authorization": f"{token}"}
59
+ self.file_manager = FileManager(base_url=base_url, headers=self.headers)
60
+
61
+ @property
62
+ def auth_id(self) -> Optional[str]:
63
+ """Current session token (authId) sent in the Authorization header, or None."""
64
+ return self.headers.get("Authorization") or None
65
+
66
+ def set_auth_id(self, auth_id: str) -> None:
67
+ """Adopt a session token so following requests are authenticated."""
68
+ # headers is shared with file_manager: mutate in place so both see the token.
69
+ self.headers["Authorization"] = auth_id
70
+
71
+ async def _request(
72
+ self,
73
+ method: str,
74
+ path: str,
75
+ *,
76
+ params: Optional[Dict[str, Any]] = None,
77
+ json: Optional[Dict[str, Any]] = None,
78
+ stream: bool = False,
79
+ return_bytes: bool = False,
80
+ ) -> Any:
81
+ """Internal HTTP request. Used by client methods and services."""
82
+ return await _http_request(
83
+ self,
84
+ method,
85
+ path,
86
+ params=params,
87
+ json=json,
88
+ stream=stream,
89
+ return_bytes=return_bytes,
90
+ )
91
+
92
+ @property
93
+ def _stream(self) -> StreamService:
94
+ if not hasattr(self, "_stream_service"):
95
+ self._stream_service = StreamService(self)
96
+ return self._stream_service
97
+
98
+ async def find_stream(
99
+ self,
100
+ module: str,
101
+ options: KonectyFindParams,
102
+ *,
103
+ include_total: bool = False,
104
+ ) -> FindStreamResult:
105
+ """
106
+ Stream records as NDJSON. Returns result with .stream (async generator) and .total (when include_total).
107
+
108
+ Example:
109
+ result = await client.find_stream("Contact", options, include_total=True)
110
+ async for record in result.stream:
111
+ ...
112
+ total = result.total
113
+ """
114
+ return await self._stream.find_stream(
115
+ module, options, include_total=include_total
116
+ )
117
+
118
+ async def count_stream(
119
+ self,
120
+ module: str,
121
+ filter_params: Optional[KonectyFilter] = None,
122
+ **kwargs: Any,
123
+ ) -> int:
124
+ """Return total count for the module (GET /rest/stream/{module}/count)."""
125
+ return await self._stream.count_stream(module, filter_params, **kwargs)
126
+
127
+ @property
128
+ def _files(self) -> FilesService:
129
+ if not hasattr(self, "_files_service"):
130
+ self._files_service = FilesService(self)
131
+ return self._files_service
132
+
133
+ async def download_file(
134
+ self,
135
+ module: str,
136
+ record_code: str,
137
+ field_name: str,
138
+ file_name: str,
139
+ ) -> bytes:
140
+ """Download a file from a record field. Returns bytes."""
141
+ return await self._files.download_file(
142
+ module, record_code, field_name, file_name
143
+ )
144
+
145
+ async def download_image(
146
+ self,
147
+ module: str,
148
+ record_id: str,
149
+ field_name: str,
150
+ file_name: str,
151
+ *,
152
+ style: Optional[Literal["full", "thumb", "wm"]] = None,
153
+ ) -> bytes:
154
+ """Download an image (optional style: full, thumb, wm). Returns bytes."""
155
+ return await self._files.download_image(
156
+ module, record_id, field_name, file_name, style=style
157
+ )
158
+
159
+ @property
160
+ def _aggregation(self) -> AggregationService:
161
+ if not hasattr(self, "_aggregation_service"):
162
+ self._aggregation_service = AggregationService(self)
163
+ return self._aggregation_service
164
+
165
+ async def get_kpi(
166
+ self,
167
+ module: str,
168
+ kpi_config: KpiConfig,
169
+ *,
170
+ filter_params: Optional[KonectyFilter] = None,
171
+ **kwargs: Any,
172
+ ) -> Dict[str, Any]:
173
+ """Run KPI aggregation. Returns dict with value and count."""
174
+ return await self._aggregation.get_kpi(
175
+ module, kpi_config, filter_params=filter_params, **kwargs
176
+ )
177
+
178
+ async def get_graph(
179
+ self,
180
+ module: str,
181
+ graph_config: Any,
182
+ *,
183
+ filter_params: Optional[KonectyFilter] = None,
184
+ **kwargs: Any,
185
+ ) -> str:
186
+ """Get graph as SVG string. graph_config: type, xAxis, yAxis, series, etc."""
187
+ return await self._aggregation.get_graph(
188
+ module, graph_config, filter_params=filter_params, **kwargs
189
+ )
190
+
191
+ async def get_pivot(
192
+ self,
193
+ module: str,
194
+ pivot_config: Any,
195
+ *,
196
+ filter_params: Optional[KonectyFilter] = None,
197
+ **kwargs: Any,
198
+ ) -> Dict[str, Any]:
199
+ """Get pivot table result. pivot_config: rows, columns, values."""
200
+ return await self._aggregation.get_pivot(
201
+ module, pivot_config, filter_params=filter_params, **kwargs
202
+ )
203
+
204
+ @property
205
+ def _export(self) -> ExportService:
206
+ if not hasattr(self, "_export_service"):
207
+ self._export_service = ExportService(self)
208
+ return self._export_service
209
+
210
+ async def export_list(
211
+ self,
212
+ module: str,
213
+ list_name: str,
214
+ format: Literal["csv", "xlsx", "json", "xls"],
215
+ *,
216
+ filter_params: Optional[KonectyFilter] = None,
217
+ **kwargs: Any,
218
+ ) -> bytes:
219
+ """Export list as CSV, XLSX, or JSON. Returns file bytes."""
220
+ return await self._export.export_list(
221
+ module, list_name, format, filter_params=filter_params, **kwargs
222
+ )
223
+
224
+ @property
225
+ def _comments(self) -> CommentsService:
226
+ if not hasattr(self, "_comments_service"):
227
+ self._comments_service = CommentsService(self)
228
+ return self._comments_service
229
+
230
+ async def get_comments(self, module: str, data_id: str) -> Any:
231
+ """Get comments for a record."""
232
+ return await self._comments.get_comments(module, data_id)
233
+
234
+ async def create_comment(
235
+ self, module: str, data_id: str, text: str, parent_id: Optional[str] = None
236
+ ) -> Any:
237
+ """Create a comment (optionally reply via parent_id)."""
238
+ return await self._comments.create_comment(
239
+ module, data_id, text, parent_id=parent_id
240
+ )
241
+
242
+ async def update_comment(
243
+ self, module: str, data_id: str, comment_id: str, text: str
244
+ ) -> Any:
245
+ """Update a comment."""
246
+ return await self._comments.update_comment(module, data_id, comment_id, text)
247
+
248
+ async def delete_comment(self, module: str, data_id: str, comment_id: str) -> Any:
249
+ """Delete (soft) a comment."""
250
+ return await self._comments.delete_comment(module, data_id, comment_id)
251
+
252
+ async def search_comment_users(
253
+ self, module: str, data_id: str, query: str = ""
254
+ ) -> Any:
255
+ """Search users for @mention autocomplete."""
256
+ return await self._comments.search_comment_users(module, data_id, query)
257
+
258
+ async def search_comments(
259
+ self,
260
+ module: str,
261
+ data_id: str,
262
+ *,
263
+ query: Optional[str] = None,
264
+ author_id: Optional[str] = None,
265
+ start_date: Optional[str] = None,
266
+ end_date: Optional[str] = None,
267
+ page: Optional[int] = None,
268
+ limit: Optional[int] = None,
269
+ ) -> Any:
270
+ """Search comments with filters."""
271
+ return await self._comments.search_comments(
272
+ module,
273
+ data_id,
274
+ query=query,
275
+ author_id=author_id,
276
+ start_date=start_date,
277
+ end_date=end_date,
278
+ page=page,
279
+ limit=limit,
280
+ )
281
+
282
+ @property
283
+ def _subscriptions(self) -> SubscriptionsService:
284
+ if not hasattr(self, "_subscriptions_service"):
285
+ self._subscriptions_service = SubscriptionsService(self)
286
+ return self._subscriptions_service
287
+
288
+ async def get_subscription_status(self, module: str, data_id: str) -> Any:
289
+ """Get subscription status for a record."""
290
+ return await self._subscriptions.get_subscription_status(module, data_id)
291
+
292
+ async def subscribe(self, module: str, data_id: str) -> Any:
293
+ """Subscribe to record notifications."""
294
+ return await self._subscriptions.subscribe(module, data_id)
295
+
296
+ async def unsubscribe(self, module: str, data_id: str) -> Any:
297
+ """Unsubscribe from record."""
298
+ return await self._subscriptions.unsubscribe(module, data_id)
299
+
300
+ @property
301
+ def _notifications(self) -> NotificationsService:
302
+ if not hasattr(self, "_notifications_service"):
303
+ self._notifications_service = NotificationsService(self)
304
+ return self._notifications_service
305
+
306
+ async def list_notifications(
307
+ self,
308
+ *,
309
+ read: Optional[bool] = None,
310
+ page: Optional[int] = None,
311
+ limit: Optional[int] = None,
312
+ ) -> Any:
313
+ """List user notifications."""
314
+ return await self._notifications.list_notifications(
315
+ read=read, page=page, limit=limit
316
+ )
317
+
318
+ async def get_unread_notifications_count(self) -> Any:
319
+ """Get unread notifications count."""
320
+ return await self._notifications.get_unread_count()
321
+
322
+ async def mark_notification_read(self, notification_id: str) -> Any:
323
+ """Mark a notification as read."""
324
+ return await self._notifications.mark_notification_read(notification_id)
325
+
326
+ async def mark_all_notifications_read(self) -> Any:
327
+ """Mark all notifications as read."""
328
+ return await self._notifications.mark_all_notifications_read()
329
+
330
+ @property
331
+ def _change_user(self) -> ChangeUserService:
332
+ if not hasattr(self, "_change_user_service"):
333
+ self._change_user_service = ChangeUserService(self)
334
+ return self._change_user_service
335
+
336
+ async def change_user_add(self, module: str, ids: List[Any], users: Any) -> Any:
337
+ """Add users to records."""
338
+ return await self._change_user.add_users(module, ids, users)
339
+
340
+ async def change_user_remove(self, module: str, ids: List[Any], users: Any) -> Any:
341
+ """Remove users from records."""
342
+ return await self._change_user.remove_users(module, ids, users)
343
+
344
+ async def change_user_define(self, module: str, ids: List[Any], users: Any) -> Any:
345
+ """Define users on records."""
346
+ return await self._change_user.define_users(module, ids, users)
347
+
348
+ async def change_user_replace(
349
+ self,
350
+ module: str,
351
+ ids: List[Any],
352
+ *,
353
+ from_user: Any = None,
354
+ to_user: Any = None,
355
+ ) -> Any:
356
+ """Replace user on records."""
357
+ return await self._change_user.replace_users(
358
+ module, ids, from_user=from_user, to_user=to_user
359
+ )
360
+
361
+ async def change_user_count_inactive(self, module: str, ids: List[Any]) -> Any:
362
+ """Count inactive users on records."""
363
+ return await self._change_user.count_inactive(module, ids)
364
+
365
+ async def change_user_remove_inactive(self, module: str, ids: List[Any]) -> Any:
366
+ """Remove inactive users from records."""
367
+ return await self._change_user.remove_inactive(module, ids)
368
+
369
+ async def change_user_set_queue(
370
+ self, module: str, ids: List[Any], queue: Any
371
+ ) -> Any:
372
+ """Set queue on records."""
373
+ return await self._change_user.set_queue(module, ids, queue)
374
+
375
+ @property
376
+ def _auth(self) -> AuthService:
377
+ if not hasattr(self, "_auth_service"):
378
+ self._auth_service = AuthService(self)
379
+ return self._auth_service
380
+
381
+ def google_login_url(
382
+ self,
383
+ client_id: str,
384
+ redirect_uri: Optional[str] = None,
385
+ state: Optional[str] = None,
386
+ ) -> str:
387
+ """Build the URL of GET /api/auth/google/start. Pure, performs no request."""
388
+ return self._auth.google_login_url(
389
+ client_id, redirect_uri=redirect_uri, state=state
390
+ )
391
+
392
+ async def exchange_google_code(
393
+ self,
394
+ code: str,
395
+ *,
396
+ geolocation: Optional[Union[Dict[str, Any], str]] = None,
397
+ resolution: Optional[Union[Dict[str, Any], str]] = None,
398
+ source: Optional[str] = None,
399
+ fingerprint: Optional[str] = None,
400
+ ) -> Dict[str, Any]:
401
+ """
402
+ Exchange the single-use code from the callback for a session
403
+ (POST /api/auth/google/session).
404
+
405
+ On success adopts the returned authId in this client. On error raises
406
+ KonectyAPIError with errors[0].message and keeps the client unauthenticated.
407
+ """
408
+ return await self._auth.exchange_google_code(
409
+ code,
410
+ geolocation=geolocation,
411
+ resolution=resolution,
412
+ source=source,
413
+ fingerprint=fingerprint,
414
+ )
415
+
416
+ async def get_login_options(self) -> Dict[str, Any]:
417
+ """Get login method flags for the namespace (GET /api/auth/login-options)."""
418
+ return await self._auth.get_login_options()
419
+
420
+ @property
421
+ def _query(self) -> QueryService:
422
+ if not hasattr(self, "_query_service"):
423
+ self._query_service = QueryService(self)
424
+ return self._query_service
425
+
426
+ async def execute_query_json(
427
+ self,
428
+ body: Any,
429
+ *,
430
+ include_total: bool = True,
431
+ include_meta: bool = False,
432
+ ) -> QueryResult:
433
+ """Execute cross-module query (POST /rest/query/json). Returns QueryResult with .stream, .total, .meta."""
434
+ return await self._query.execute_query_json(
435
+ body, include_total=include_total, include_meta=include_meta
436
+ )
437
+
438
+ async def execute_query_sql(
439
+ self,
440
+ sql: str,
441
+ *,
442
+ include_total: bool = True,
443
+ include_meta: bool = False,
444
+ ) -> QueryResult:
445
+ """Execute SQL query (POST /rest/query/sql). Returns QueryResult. SQL parse errors return 400."""
446
+ return await self._query.execute_query_sql(
447
+ sql, include_total=include_total, include_meta=include_meta
448
+ )
449
+
450
+ async def list_saved_queries(self) -> Any:
451
+ """List saved queries."""
452
+ return await self._query.list_saved_queries()
453
+
454
+ async def get_saved_query(self, query_id: str) -> Any:
455
+ """Get a saved query by id."""
456
+ return await self._query.get_saved_query(query_id)
457
+
458
+ async def create_saved_query(
459
+ self,
460
+ name: str,
461
+ query: Dict[str, Any],
462
+ description: Optional[str] = None,
463
+ ) -> Any:
464
+ """Create a saved query."""
465
+ return await self._query.create_saved_query(
466
+ name, query, description=description
467
+ )
468
+
469
+ async def update_saved_query(
470
+ self,
471
+ query_id: str,
472
+ *,
473
+ name: Optional[str] = None,
474
+ description: Optional[str] = None,
475
+ query: Optional[Dict[str, Any]] = None,
476
+ ) -> Any:
477
+ """Update a saved query."""
478
+ return await self._query.update_saved_query(
479
+ query_id, name=name, description=description, query=query
480
+ )
481
+
482
+ async def delete_saved_query(self, query_id: str) -> Any:
483
+ """Delete a saved query."""
484
+ return await self._query.delete_saved_query(query_id)
485
+
486
+ async def share_saved_query(
487
+ self,
488
+ query_id: str,
489
+ shared_with: List[Dict[str, Any]],
490
+ is_public: Optional[bool] = None,
491
+ ) -> Any:
492
+ """Share a saved query."""
493
+ return await self._query.share_saved_query(
494
+ query_id, shared_with, is_public=is_public
495
+ )
496
+
497
+ async def find(self, module: str, options: KonectyFindParams) -> List[KonectyDict]:
498
+ params: Dict[str, str] = {}
499
+ for key, value in options.model_dump(exclude_none=True).items():
500
+ params[key] = (
501
+ json.dumps(value, default=json_serial)
502
+ if key != "fields"
503
+ else ",".join(value)
504
+ )
505
+
506
+ async with (
507
+ aiohttp.ClientSession() as session,
508
+ session.get(
509
+ f"{self.base_url}/rest/data/{module}/find",
510
+ params=params,
511
+ headers={"Authorization": self.headers["Authorization"]},
512
+ ) as response,
513
+ ):
514
+ response.raise_for_status()
515
+ result = await response.json()
516
+ if not result.get("success", False):
517
+ errors = result.get("errors", [])
518
+ logger.error(errors)
519
+ raise KonectyAPIError(errors)
520
+ data = result.get("data", [])
521
+ return cast(List[KonectyDict], data)
522
+
523
+ async def lookup(self, module: str, lookup_field: str, options: KonectyFindParams, search: str = "") -> List[KonectyDict]:
524
+ params: Dict[str, str] = {}
525
+ for key, value in options.model_dump(exclude_none=True).items():
526
+ params[key] = (
527
+ json.dumps(value, default=json_serial)
528
+ if key != "fields"
529
+ else ",".join(value)
530
+ )
531
+ if search:
532
+ params["search"] = search
533
+
534
+ print(f"params: {params}")
535
+ async with (
536
+ aiohttp.ClientSession() as session,
537
+ session.get(
538
+ f"{self.base_url}/rest/data/{module}/lookup/{lookup_field}",
539
+ params=params,
540
+ headers={"Authorization": self.headers["Authorization"]},
541
+ ) as response,
542
+ ):
543
+ response.raise_for_status()
544
+ result = await response.json()
545
+ if not result.get("success", False):
546
+ errors = result.get("errors", [])
547
+ logger.error(errors)
548
+ raise KonectyAPIError(errors)
549
+ data = result.get("data", [])
550
+ return cast(List[KonectyDict], data)
551
+
552
+ def find_sync(self, module: str, options: KonectyFindParams) -> List[KonectyDict]:
553
+ """Versão síncrona de find."""
554
+ params: Dict[str, str] = {}
555
+ for key, value in options.model_dump(exclude_none=True).items():
556
+ params[key] = (
557
+ json.dumps(value, default=json_serial)
558
+ if key != "fields"
559
+ else ",".join(value)
560
+ )
561
+
562
+ import requests
563
+
564
+ response = requests.get(
565
+ f"{self.base_url}/rest/data/{module}/find",
566
+ params=params,
567
+ headers={"Authorization": self.headers["Authorization"]},
568
+ )
569
+ response.raise_for_status()
570
+ result = response.json()
571
+ if not result.get("success", False):
572
+ errors = result.get("errors", [])
573
+ logger.error(errors)
574
+ raise KonectyAPIError(errors)
575
+ data = result.get("data", [])
576
+ return cast(List[KonectyDict], data)
577
+
578
+ def find_one_sync(
579
+ self, module: str, filter_params: KonectyFilter
580
+ ) -> Optional[KonectyDict]:
581
+ """Versão síncrona de find_one."""
582
+ find_params = KonectyFindParams(filter=filter_params, limit=1)
583
+ result = self.find_sync(module, find_params)
584
+ if not result:
585
+ return None
586
+ return cast(KonectyDict, result[0]) if isinstance(result[0], dict) else None
587
+
588
+ async def find_by_id(self, module: str, id: str) -> Optional[KonectyDict]:
589
+ async with (
590
+ aiohttp.ClientSession() as session,
591
+ session.get(
592
+ f"{self.base_url}/rest/data/{module}/{id}",
593
+ headers={"Authorization": self.headers["Authorization"]},
594
+ ) as response,
595
+ ):
596
+ response.raise_for_status()
597
+ result = await response.json()
598
+ if not result.get("success", False):
599
+ errors = result.get("errors", [])
600
+ logger.error(errors)
601
+ raise KonectyAPIError(errors)
602
+ data = result.get("data", [None])
603
+ return get_first_dict(data)
604
+
605
+ async def find_one(
606
+ self, module: str, filter_params: KonectyFilter
607
+ ) -> Optional[KonectyDict]:
608
+ find_params = KonectyFindParams(filter=filter_params, limit=1)
609
+ result = await self.find(module, find_params)
610
+ if not result:
611
+ return None
612
+ return cast(KonectyDict, result[0]) if isinstance(result[0], dict) else None
613
+
614
+ async def create(self, module: str, data: KonectyDict) -> Optional[KonectyDict]:
615
+ endpoint = f"/rest/data/{module}"
616
+ cleaned_data = {
617
+ k: v for k, v in data.items() if k not in KONECTY_CREATE_IGNORE_FIELDS
618
+ }
619
+ async with (
620
+ aiohttp.ClientSession(base_url=self.base_url) as session,
621
+ session.post(
622
+ endpoint,
623
+ headers=self.headers,
624
+ json=json.loads(json.dumps(cleaned_data, default=json_serial)),
625
+ ) as response,
626
+ ):
627
+ result = await response.json()
628
+ if not result.get("success", False):
629
+ errors = result.get("errors", [])
630
+ raise KonectyAPIError(errors)
631
+ result_data: list[KonectyDict] = result.get("data", [])
632
+ if not result_data:
633
+ return None
634
+ return result_data[0]
635
+
636
+ async def update_one(
637
+ self, module: str, id: str, updatedAt: datetime, data: KonectyDict
638
+ ) -> Optional[KonectyDict]:
639
+ endpoint = f"/rest/data/{module}"
640
+ cleaned_data = {
641
+ k: v for k, v in data.items() if k not in KONECTY_UPDATE_IGNORE_FIELDS
642
+ }
643
+ payload = {
644
+ "ids": [
645
+ {
646
+ "_id": id,
647
+ "_updatedAt": KonectyDateTime.from_datetime(updatedAt).to_json(),
648
+ }
649
+ ],
650
+ "data": json.loads(json.dumps(cleaned_data, default=json_serial)),
651
+ }
652
+ async with (
653
+ aiohttp.ClientSession(base_url=self.base_url) as session,
654
+ session.put(
655
+ endpoint,
656
+ headers=self.headers,
657
+ json=json.loads(json.dumps(payload, default=json_serial)),
658
+ ) as response,
659
+ ):
660
+ result = await response.json()
661
+ if not result.get("success", False):
662
+ errors = result.get("errors", [])
663
+ raise KonectyAPIError(errors)
664
+ return result.get("data", [None])[0]
665
+
666
+ async def update(
667
+ self, module: str, ids: list[KonectyUpdateId], data: KonectyDict
668
+ ) -> list[KonectyDict]:
669
+ endpoint = f"/rest/data/{module}"
670
+ cleaned_data = {
671
+ k: v for k, v in data.items() if k not in KONECTY_UPDATE_IGNORE_FIELDS
672
+ }
673
+ payload = {
674
+ "ids": [id.to_dict() for id in ids],
675
+ "data": json.loads(json.dumps(cleaned_data, default=json_serial)),
676
+ }
677
+ async with (
678
+ aiohttp.ClientSession(base_url=self.base_url) as session,
679
+ session.put(endpoint, headers=self.headers, json=payload) as response,
680
+ ):
681
+ result = await response.json()
682
+ if not result.get("success", False):
683
+ errors = result.get("errors", [])
684
+ raise KonectyAPIError(errors)
685
+ return result.get("data", [])
686
+
687
+ async def delete_one(
688
+ self, module: str, id: str, updatedAt: datetime
689
+ ) -> Optional[KonectyDict]:
690
+ endpoint = f"/rest/data/{module}"
691
+ payload = {
692
+ "ids": [
693
+ {
694
+ "_id": id,
695
+ "_updatedAt": KonectyDateTime.from_datetime(updatedAt).to_json(),
696
+ }
697
+ ],
698
+ }
699
+ async with (
700
+ aiohttp.ClientSession(base_url=self.base_url) as session,
701
+ session.delete(endpoint, headers=self.headers, json=payload) as response,
702
+ ):
703
+ result = await response.json()
704
+ return result.get("data", [None])[0]
705
+
706
+ async def get_document(self, document_id: str) -> Optional[KonectyDict]:
707
+ """Obtém o documento do Konecty."""
708
+ endpoint = f"/rest/menu/documents/{document_id}"
709
+ async with (
710
+ aiohttp.ClientSession(base_url=self.base_url) as session,
711
+ session.get(endpoint, headers=self.headers) as response,
712
+ ):
713
+ result = await response.json()
714
+ if result is None:
715
+ logger.error(f"Documento {document_id} não encontrado")
716
+ return None
717
+ if isinstance(result, dict):
718
+ return cast(KonectyDict, result)
719
+ logger.error(f"Documento {document_id} retornou formato inválido")
720
+ return None
721
+
722
+ async def get_schema(self, document_id: str) -> Optional[KonectyDict]:
723
+ """Obtém o schema do documento e gera um modelo Pydantic."""
724
+ try:
725
+ document = await self.get_document(document_id)
726
+ if document is None:
727
+ return None
728
+ return document
729
+ except Exception as e:
730
+ logger.error(f"Erro ao obter schema do documento {document_id}: {e}")
731
+ return None
732
+
733
+ async def get_setting(self, key: str) -> Optional[str]:
734
+ """Obtém uma configuração do Konecty."""
735
+ setting = await self.find_one(
736
+ "Setting", KonectyFilter.create().add_condition("name", "equals", key)
737
+ )
738
+ if setting is None:
739
+ return None
740
+ return cast(str, setting.get("value"))
741
+
742
+ def get_setting_sync(self, key: str) -> Optional[str]:
743
+ """Versão síncrona de get_setting."""
744
+ setting = self.find_one_sync(
745
+ "Setting", KonectyFilter.create().add_condition("name", "equals", key)
746
+ )
747
+ if setting is None:
748
+ return None
749
+ return cast(str, setting.get("value"))
750
+
751
+ async def get_settings(
752
+ self, keys: List[str], *, module: str | None = None
753
+ ) -> Dict[str, str]:
754
+ """Obtém múltiplas configurações do Konecty.
755
+
756
+ Args:
757
+ keys: Lista de chaves das configurações a serem obtidas
758
+
759
+ Returns:
760
+ Dicionário com as chaves e seus respectivos valores. Chaves não encontradas terão valor None.
761
+ """
762
+ if not keys:
763
+ return {}
764
+
765
+ filter_params = KonectyFilter.create().add_condition("name", "in", keys)
766
+ if module:
767
+ filter_params.add_condition("module", "equals", module)
768
+ find_params = KonectyFindParams(filter=filter_params)
769
+
770
+ settings = await self.find("Setting", find_params)
771
+
772
+ result = {}
773
+
774
+ for setting in settings:
775
+ key = setting.get("name")
776
+ value = setting.get("value")
777
+ result[key] = cast(str, value)
778
+
779
+ return result
780
+
781
+ def get_settings_sync(
782
+ self, keys: List[str], *, module: str | None = None
783
+ ) -> Dict[str, str]:
784
+ """Versão síncrona de get_settings.
785
+
786
+ Args:
787
+ keys: Lista de chaves das configurações a serem obtidas
788
+
789
+ Returns:
790
+ Dicionário com as chaves e seus respectivos valores. Chaves não encontradas terão valor None.
791
+ """
792
+ if not keys:
793
+ return {}
794
+
795
+ filter_params = KonectyFilter.create().add_condition("name", "in", keys)
796
+ if module:
797
+ filter_params.add_condition("module", "equals", module)
798
+ find_params = KonectyFindParams(filter=filter_params)
799
+
800
+ settings = self.find_sync("Setting", find_params)
801
+
802
+ result = {}
803
+
804
+ for setting in settings:
805
+ key = setting.get("name")
806
+ value = setting.get("value")
807
+ result[key] = cast(str, value)
808
+
809
+ return result
810
+
811
+ async def count_documents(self, module: str, filter_params: KonectyFilter) -> int:
812
+ params: Dict[str, str] = {}
813
+ options = KonectyFindParams(
814
+ filter=filter_params,
815
+ fields=["_id"],
816
+ limit=1,
817
+ )
818
+
819
+ for key, value in options.model_dump(exclude_none=True).items():
820
+ params[key] = (
821
+ json.dumps(value, default=json_serial)
822
+ if key != "fields"
823
+ else ",".join(value)
824
+ )
825
+
826
+ async with (
827
+ aiohttp.ClientSession() as session,
828
+ session.get(
829
+ f"{self.base_url}/rest/data/{module}/find",
830
+ params=params,
831
+ headers={"Authorization": self.headers["Authorization"]},
832
+ ) as response,
833
+ ):
834
+ response.raise_for_status()
835
+ result = await response.json()
836
+ if not result.get("success", False):
837
+ errors = result.get("errors", [])
838
+ logger.error(errors)
839
+ raise KonectyAPIError(errors)
840
+ count = result.get("total", 0)
841
+ return count
842
+
843
+ async def upload_file(
844
+ self,
845
+ module: str,
846
+ record_code: str,
847
+ field_name: str,
848
+ file: Union[bytes, str, AsyncGenerator[bytes, None]],
849
+ file_name: Optional[str] = None,
850
+ file_type: Optional[str] = None,
851
+ ) -> str:
852
+ """
853
+ Upload a file to a specific record field in Konecty.
854
+
855
+ Parameters
856
+ ----------
857
+ module : str
858
+ The module name where the record is located (e.g., 'Contact', 'User').
859
+ record_code : str
860
+ The unique identifier code of the record.
861
+ field_name : str
862
+ The name of the field where the file will be uploaded.
863
+ file : Union[bytes, str]
864
+ The file to upload. Can be:
865
+ - bytes: Raw file content (file_name is required)
866
+ - str: URL to the file (file_name is optional; if not provided, will use the last segment of the URL)
867
+ file_name : Optional[str], default=None
868
+ The name to use for the file when uploaded. Required when 'file' is bytes, optional otherwise.
869
+
870
+ Returns
871
+ -------
872
+ str
873
+ The file key (ID) assigned by Konecty, which can be used for referencing the file in future operations.
874
+
875
+ Raises
876
+ ------
877
+ ValueError
878
+ If file_name is not provided when file is bytes, or if the file is empty or invalid.
879
+ TypeError
880
+ If the file argument is not bytes or a URL string.
881
+ KonectyError
882
+ If the API returns an error response.
883
+ HTTPError
884
+ If there is an HTTP connection error.
885
+
886
+ Limitations
887
+ -----------
888
+ - Maximum file size: 20 MB (enforced by server configuration; see nginx.conf).
889
+ - Only one file per call is supported.
890
+ - Progress tracking is not available in this version.
891
+
892
+ Examples
893
+ --------
894
+ Upload a file using a file path as string:
895
+ >>> file_id = await client.upload_file(
896
+ ... module='Contact',
897
+ ... record_code='ABC123',
898
+ ... field_name='attachments',
899
+ ... file='/path/to/document.pdf'
900
+ ... )
901
+
902
+ Upload a file using a Path object:
903
+ >>> from pathlib import Path
904
+ >>> file_path = Path('/path/to/image.jpg')
905
+ >>> file_id = await client.upload_file(
906
+ ... module='Contact',
907
+ ... record_code='ABC123',
908
+ ... field_name='photo',
909
+ ... file=file_path
910
+ ... )
911
+
912
+ Upload file content from bytes with a custom filename:
913
+ >>> with open('document.pdf', 'rb') as f:
914
+ ... file_content = f.read()
915
+ >>> file_id = await client.upload_file(
916
+ ... module='Document',
917
+ ... record_code='XYZ789',
918
+ ... field_name='file',
919
+ ... file=file_content,
920
+ ... file_name='important_document.pdf'
921
+ ... )
922
+
923
+ Handling errors:
924
+ >>> try:
925
+ ... file_id = await client.upload_file(
926
+ ... module='Contact',
927
+ ... record_code='INVALID',
928
+ ... field_name='attachments',
929
+ ... file='/invalid/path/to/file.pdf'
930
+ ... )
931
+ ... except FileNotFoundError as e:
932
+ ... print(f"File not found: {e}")
933
+ ... except ValueError as e:
934
+ ... print(f"Validation error: {e}")
935
+ ... except KonectyError as e:
936
+ ... print(f"API error: {e}")
937
+
938
+ """
939
+ result = await self.file_manager.upload_file(
940
+ module=module,
941
+ record_code=record_code,
942
+ field_name=field_name,
943
+ file=file,
944
+ file_name=file_name,
945
+ file_type=file_type,
946
+ )
947
+
948
+ if not result.get("success", False):
949
+ self.file_manager.handle_error_response(result)
950
+ return result.get("key", "")