konecty-sdk-python 1.2.3__tar.gz → 2.0.1__tar.gz

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 (33) hide show
  1. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/lib/client.py +402 -27
  2. konecty_sdk_python-2.0.1/KonectySdkPython/lib/exceptions.py +26 -0
  3. konecty_sdk_python-2.0.1/KonectySdkPython/lib/feature_types/__init__.py +22 -0
  4. konecty_sdk_python-2.0.1/KonectySdkPython/lib/feature_types/cross_module_query.py +72 -0
  5. konecty_sdk_python-2.0.1/KonectySdkPython/lib/feature_types/kpi.py +21 -0
  6. konecty_sdk_python-2.0.1/KonectySdkPython/lib/http.py +95 -0
  7. konecty_sdk_python-2.0.1/KonectySdkPython/lib/serialization.py +13 -0
  8. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/__init__.py +5 -0
  9. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/aggregation.py +142 -0
  10. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/base.py +63 -0
  11. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/change_user.py +64 -0
  12. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/comments.py +89 -0
  13. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/export.py +54 -0
  14. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/files.py +59 -0
  15. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/notifications.py +41 -0
  16. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/query.py +190 -0
  17. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/stream.py +133 -0
  18. konecty_sdk_python-2.0.1/KonectySdkPython/lib/services/subscriptions.py +26 -0
  19. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/lib/types.py +2 -0
  20. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/PKG-INFO +1 -1
  21. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/pyproject.toml +1 -1
  22. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/.gitignore +0 -0
  23. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/__init__.py +0 -0
  24. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/cli/__init__.py +0 -0
  25. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/cli/apply.py +0 -0
  26. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/cli/backup.py +0 -0
  27. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/cli/pull.py +0 -0
  28. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/lib/__init__.py +0 -0
  29. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/lib/file_manager.py +0 -0
  30. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/lib/filters.py +0 -0
  31. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/lib/model.py +0 -0
  32. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/KonectySdkPython/lib/settings.py +0 -0
  33. {konecty_sdk_python-1.2.3 → konecty_sdk_python-2.0.1}/README.md +0 -0
@@ -3,21 +3,37 @@
3
3
  import json
4
4
  import logging
5
5
  from datetime import datetime
6
- from typing import Any, AsyncGenerator, Dict, List, Optional, Union, cast
6
+ from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Union, cast
7
7
 
8
8
  import aiohttp
9
9
 
10
+ from .exceptions import (
11
+ KonectyAPIError,
12
+ KonectyError,
13
+ KonectyValidationError,
14
+ )
10
15
  from .file_manager import FileManager
11
16
  from .filters import KonectyFilter, KonectyFindParams
12
- from .types import KonectyDateTime, KonectyUpdateId
17
+ from .http import request as _http_request
18
+ from .http import StreamResponse
19
+ from .feature_types.kpi import KpiConfig
20
+ from .serialization import json_serial
21
+ from .services.aggregation import AggregationService
22
+ from .services.change_user import ChangeUserService
23
+ from .services.comments import CommentsService
24
+ from .services.export import ExportService
25
+ from .services.files import FilesService
26
+ from .services.notifications import NotificationsService
27
+ from .services.query import QueryResult, QueryService
28
+ from .services.stream import FindStreamResult, StreamService
29
+ from .services.subscriptions import SubscriptionsService
30
+ from .types import KonectyDateTime, KonectyDict, KonectyUpdateId
13
31
 
14
32
  # Configura o logger do urllib3 para mostrar apenas erros
15
33
  logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
16
34
 
17
35
  logger = logging.getLogger(__name__)
18
36
 
19
- KonectyDict = Dict[str, Any]
20
-
21
37
  KONECTY_UPDATE_IGNORE_FIELDS = [
22
38
  "_id",
23
39
  "code",
@@ -39,43 +55,402 @@ def get_first_dict(items: List[Any]) -> Optional[KonectyDict]:
39
55
  return None
40
56
 
41
57
 
42
- class KonectyError(Exception):
43
- """Exceção base para erros do Konecty."""
58
+ class KonectyClient:
59
+ def __init__(self, base_url: str, token: str) -> None:
60
+ self.base_url = base_url
61
+ self.headers = {"Authorization": f"{token}"}
62
+ self.file_manager = FileManager(base_url=base_url, headers=self.headers)
44
63
 
45
- pass
64
+ async def _request(
65
+ self,
66
+ method: str,
67
+ path: str,
68
+ *,
69
+ params: Optional[Dict[str, Any]] = None,
70
+ json: Optional[Dict[str, Any]] = None,
71
+ stream: bool = False,
72
+ return_bytes: bool = False,
73
+ ) -> Any:
74
+ """Internal HTTP request. Used by client methods and services."""
75
+ return await _http_request(
76
+ self,
77
+ method,
78
+ path,
79
+ params=params,
80
+ json=json,
81
+ stream=stream,
82
+ return_bytes=return_bytes,
83
+ )
46
84
 
85
+ @property
86
+ def _stream(self) -> StreamService:
87
+ if not hasattr(self, "_stream_service"):
88
+ self._stream_service = StreamService(self)
89
+ return self._stream_service
47
90
 
48
- class KonectyAPIError(KonectyError):
49
- """Exceção para erros da API."""
91
+ async def find_stream(
92
+ self,
93
+ module: str,
94
+ options: KonectyFindParams,
95
+ *,
96
+ include_total: bool = False,
97
+ ) -> FindStreamResult:
98
+ """
99
+ Stream records as NDJSON. Returns result with .stream (async generator) and .total (when include_total).
50
100
 
51
- pass
101
+ Example:
102
+ result = await client.find_stream("Contact", options, include_total=True)
103
+ async for record in result.stream:
104
+ ...
105
+ total = result.total
106
+ """
107
+ return await self._stream.find_stream(module, options, include_total=include_total)
52
108
 
109
+ async def count_stream(
110
+ self,
111
+ module: str,
112
+ filter_params: Optional[KonectyFilter] = None,
113
+ **kwargs: Any,
114
+ ) -> int:
115
+ """Return total count for the module (GET /rest/stream/{module}/count)."""
116
+ return await self._stream.count_stream(module, filter_params, **kwargs)
117
+
118
+ @property
119
+ def _files(self) -> FilesService:
120
+ if not hasattr(self, "_files_service"):
121
+ self._files_service = FilesService(self)
122
+ return self._files_service
123
+
124
+ async def download_file(
125
+ self,
126
+ module: str,
127
+ record_code: str,
128
+ field_name: str,
129
+ file_name: str,
130
+ ) -> bytes:
131
+ """Download a file from a record field. Returns bytes."""
132
+ return await self._files.download_file(
133
+ module, record_code, field_name, file_name
134
+ )
53
135
 
54
- class KonectyValidationError(KonectyError):
55
- """Exceção para erros de validação."""
136
+ async def download_image(
137
+ self,
138
+ module: str,
139
+ record_id: str,
140
+ field_name: str,
141
+ file_name: str,
142
+ *,
143
+ style: Optional[Literal["full", "thumb", "wm"]] = None,
144
+ ) -> bytes:
145
+ """Download an image (optional style: full, thumb, wm). Returns bytes."""
146
+ return await self._files.download_image(
147
+ module, record_id, field_name, file_name, style=style
148
+ )
56
149
 
57
- pass
150
+ @property
151
+ def _aggregation(self) -> AggregationService:
152
+ if not hasattr(self, "_aggregation_service"):
153
+ self._aggregation_service = AggregationService(self)
154
+ return self._aggregation_service
58
155
 
156
+ async def get_kpi(
157
+ self,
158
+ module: str,
159
+ kpi_config: KpiConfig,
160
+ *,
161
+ filter_params: Optional[KonectyFilter] = None,
162
+ **kwargs: Any,
163
+ ) -> Dict[str, Any]:
164
+ """Run KPI aggregation. Returns dict with value and count."""
165
+ return await self._aggregation.get_kpi(
166
+ module, kpi_config, filter_params=filter_params, **kwargs
167
+ )
59
168
 
60
- class KonectySerializationError(KonectyError):
61
- """Exceção para erros de serialização."""
169
+ async def get_graph(
170
+ self,
171
+ module: str,
172
+ graph_config: Any,
173
+ *,
174
+ filter_params: Optional[KonectyFilter] = None,
175
+ **kwargs: Any,
176
+ ) -> str:
177
+ """Get graph as SVG string. graph_config: type, xAxis, yAxis, series, etc."""
178
+ return await self._aggregation.get_graph(
179
+ module, graph_config, filter_params=filter_params, **kwargs
180
+ )
62
181
 
63
- def __init__(self) -> None:
64
- super().__init__("Tipo não serializável")
182
+ async def get_pivot(
183
+ self,
184
+ module: str,
185
+ pivot_config: Any,
186
+ *,
187
+ filter_params: Optional[KonectyFilter] = None,
188
+ **kwargs: Any,
189
+ ) -> Dict[str, Any]:
190
+ """Get pivot table result. pivot_config: rows, columns, values."""
191
+ return await self._aggregation.get_pivot(
192
+ module, pivot_config, filter_params=filter_params, **kwargs
193
+ )
65
194
 
195
+ @property
196
+ def _export(self) -> ExportService:
197
+ if not hasattr(self, "_export_service"):
198
+ self._export_service = ExportService(self)
199
+ return self._export_service
66
200
 
67
- def json_serial(obj: Any) -> str:
68
- """Serializa objetos para JSON."""
69
- if isinstance(obj, datetime):
70
- return {"$date": obj.isoformat()}
71
- raise KonectySerializationError()
201
+ async def export_list(
202
+ self,
203
+ module: str,
204
+ list_name: str,
205
+ format: Literal["csv", "xlsx", "json", "xls"],
206
+ *,
207
+ filter_params: Optional[KonectyFilter] = None,
208
+ **kwargs: Any,
209
+ ) -> bytes:
210
+ """Export list as CSV, XLSX, or JSON. Returns file bytes."""
211
+ return await self._export.export_list(
212
+ module, list_name, format, filter_params=filter_params, **kwargs
213
+ )
72
214
 
215
+ @property
216
+ def _comments(self) -> CommentsService:
217
+ if not hasattr(self, "_comments_service"):
218
+ self._comments_service = CommentsService(self)
219
+ return self._comments_service
220
+
221
+ async def get_comments(self, module: str, data_id: str) -> Any:
222
+ """Get comments for a record."""
223
+ return await self._comments.get_comments(module, data_id)
224
+
225
+ async def create_comment(
226
+ self, module: str, data_id: str, text: str, parent_id: Optional[str] = None
227
+ ) -> Any:
228
+ """Create a comment (optionally reply via parent_id)."""
229
+ return await self._comments.create_comment(
230
+ module, data_id, text, parent_id=parent_id
231
+ )
73
232
 
74
- class KonectyClient:
75
- def __init__(self, base_url: str, token: str) -> None:
76
- self.base_url = base_url
77
- self.headers = {"Authorization": f"{token}"}
78
- self.file_manager = FileManager(base_url=base_url, headers=self.headers)
233
+ async def update_comment(
234
+ self, module: str, data_id: str, comment_id: str, text: str
235
+ ) -> Any:
236
+ """Update a comment."""
237
+ return await self._comments.update_comment(
238
+ module, data_id, comment_id, text
239
+ )
240
+
241
+ async def delete_comment(
242
+ self, module: str, data_id: str, comment_id: str
243
+ ) -> Any:
244
+ """Delete (soft) a comment."""
245
+ return await self._comments.delete_comment(module, data_id, comment_id)
246
+
247
+ async def search_comment_users(
248
+ self, module: str, data_id: str, query: str = ""
249
+ ) -> Any:
250
+ """Search users for @mention autocomplete."""
251
+ return await self._comments.search_comment_users(
252
+ module, data_id, query
253
+ )
254
+
255
+ async def search_comments(
256
+ self,
257
+ module: str,
258
+ data_id: str,
259
+ *,
260
+ query: Optional[str] = None,
261
+ author_id: Optional[str] = None,
262
+ start_date: Optional[str] = None,
263
+ end_date: Optional[str] = None,
264
+ page: Optional[int] = None,
265
+ limit: Optional[int] = None,
266
+ ) -> Any:
267
+ """Search comments with filters."""
268
+ return await self._comments.search_comments(
269
+ module,
270
+ data_id,
271
+ query=query,
272
+ author_id=author_id,
273
+ start_date=start_date,
274
+ end_date=end_date,
275
+ page=page,
276
+ limit=limit,
277
+ )
278
+
279
+ @property
280
+ def _subscriptions(self) -> SubscriptionsService:
281
+ if not hasattr(self, "_subscriptions_service"):
282
+ self._subscriptions_service = SubscriptionsService(self)
283
+ return self._subscriptions_service
284
+
285
+ async def get_subscription_status(self, module: str, data_id: str) -> Any:
286
+ """Get subscription status for a record."""
287
+ return await self._subscriptions.get_subscription_status(module, data_id)
288
+
289
+ async def subscribe(self, module: str, data_id: str) -> Any:
290
+ """Subscribe to record notifications."""
291
+ return await self._subscriptions.subscribe(module, data_id)
292
+
293
+ async def unsubscribe(self, module: str, data_id: str) -> Any:
294
+ """Unsubscribe from record."""
295
+ return await self._subscriptions.unsubscribe(module, data_id)
296
+
297
+ @property
298
+ def _notifications(self) -> NotificationsService:
299
+ if not hasattr(self, "_notifications_service"):
300
+ self._notifications_service = NotificationsService(self)
301
+ return self._notifications_service
302
+
303
+ async def list_notifications(
304
+ self,
305
+ *,
306
+ read: Optional[bool] = None,
307
+ page: Optional[int] = None,
308
+ limit: Optional[int] = None,
309
+ ) -> Any:
310
+ """List user notifications."""
311
+ return await self._notifications.list_notifications(
312
+ read=read, page=page, limit=limit
313
+ )
314
+
315
+ async def get_unread_notifications_count(self) -> Any:
316
+ """Get unread notifications count."""
317
+ return await self._notifications.get_unread_count()
318
+
319
+ async def mark_notification_read(self, notification_id: str) -> Any:
320
+ """Mark a notification as read."""
321
+ return await self._notifications.mark_notification_read(notification_id)
322
+
323
+ async def mark_all_notifications_read(self) -> Any:
324
+ """Mark all notifications as read."""
325
+ return await self._notifications.mark_all_notifications_read()
326
+
327
+ @property
328
+ def _change_user(self) -> ChangeUserService:
329
+ if not hasattr(self, "_change_user_service"):
330
+ self._change_user_service = ChangeUserService(self)
331
+ return self._change_user_service
332
+
333
+ async def change_user_add(
334
+ self, module: str, ids: List[Any], users: Any
335
+ ) -> Any:
336
+ """Add users to records."""
337
+ return await self._change_user.add_users(module, ids, users)
338
+
339
+ async def change_user_remove(
340
+ self, module: str, ids: List[Any], users: Any
341
+ ) -> Any:
342
+ """Remove users from records."""
343
+ return await self._change_user.remove_users(module, ids, users)
344
+
345
+ async def change_user_define(
346
+ self, module: str, ids: List[Any], users: Any
347
+ ) -> Any:
348
+ """Define users on records."""
349
+ return await self._change_user.define_users(module, ids, users)
350
+
351
+ async def change_user_replace(
352
+ self,
353
+ module: str,
354
+ ids: List[Any],
355
+ *,
356
+ from_user: Any = None,
357
+ to_user: Any = None,
358
+ ) -> Any:
359
+ """Replace user on records."""
360
+ return await self._change_user.replace_users(
361
+ module, ids, from_user=from_user, to_user=to_user
362
+ )
363
+
364
+ async def change_user_count_inactive(self, module: str, ids: List[Any]) -> Any:
365
+ """Count inactive users on records."""
366
+ return await self._change_user.count_inactive(module, ids)
367
+
368
+ async def change_user_remove_inactive(self, module: str, ids: List[Any]) -> Any:
369
+ """Remove inactive users from records."""
370
+ return await self._change_user.remove_inactive(module, ids)
371
+
372
+ async def change_user_set_queue(
373
+ self, module: str, ids: List[Any], queue: Any
374
+ ) -> Any:
375
+ """Set queue on records."""
376
+ return await self._change_user.set_queue(module, ids, queue)
377
+
378
+ @property
379
+ def _query(self) -> QueryService:
380
+ if not hasattr(self, "_query_service"):
381
+ self._query_service = QueryService(self)
382
+ return self._query_service
383
+
384
+ async def execute_query_json(
385
+ self,
386
+ body: Any,
387
+ *,
388
+ include_total: bool = True,
389
+ include_meta: bool = False,
390
+ ) -> QueryResult:
391
+ """Execute cross-module query (POST /rest/query/json). Returns QueryResult with .stream, .total, .meta."""
392
+ return await self._query.execute_query_json(
393
+ body, include_total=include_total, include_meta=include_meta
394
+ )
395
+
396
+ async def execute_query_sql(
397
+ self,
398
+ sql: str,
399
+ *,
400
+ include_total: bool = True,
401
+ include_meta: bool = False,
402
+ ) -> QueryResult:
403
+ """Execute SQL query (POST /rest/query/sql). Returns QueryResult. SQL parse errors return 400."""
404
+ return await self._query.execute_query_sql(
405
+ sql, include_total=include_total, include_meta=include_meta
406
+ )
407
+
408
+ async def list_saved_queries(self) -> Any:
409
+ """List saved queries."""
410
+ return await self._query.list_saved_queries()
411
+
412
+ async def get_saved_query(self, query_id: str) -> Any:
413
+ """Get a saved query by id."""
414
+ return await self._query.get_saved_query(query_id)
415
+
416
+ async def create_saved_query(
417
+ self,
418
+ name: str,
419
+ query: Dict[str, Any],
420
+ description: Optional[str] = None,
421
+ ) -> Any:
422
+ """Create a saved query."""
423
+ return await self._query.create_saved_query(
424
+ name, query, description=description
425
+ )
426
+
427
+ async def update_saved_query(
428
+ self,
429
+ query_id: str,
430
+ *,
431
+ name: Optional[str] = None,
432
+ description: Optional[str] = None,
433
+ query: Optional[Dict[str, Any]] = None,
434
+ ) -> Any:
435
+ """Update a saved query."""
436
+ return await self._query.update_saved_query(
437
+ query_id, name=name, description=description, query=query
438
+ )
439
+
440
+ async def delete_saved_query(self, query_id: str) -> Any:
441
+ """Delete a saved query."""
442
+ return await self._query.delete_saved_query(query_id)
443
+
444
+ async def share_saved_query(
445
+ self,
446
+ query_id: str,
447
+ shared_with: List[Dict[str, Any]],
448
+ is_public: Optional[bool] = None,
449
+ ) -> Any:
450
+ """Share a saved query."""
451
+ return await self._query.share_saved_query(
452
+ query_id, shared_with, is_public=is_public
453
+ )
79
454
 
80
455
  async def find(self, module: str, options: KonectyFindParams) -> List[KonectyDict]:
81
456
  params: Dict[str, str] = {}
@@ -0,0 +1,26 @@
1
+ """Exceptions for the Konecty SDK."""
2
+
3
+
4
+ class KonectyError(Exception):
5
+ """Base exception for Konecty errors."""
6
+
7
+ pass
8
+
9
+
10
+ class KonectyAPIError(KonectyError):
11
+ """Raised when the API returns success=false or a non-2xx status."""
12
+
13
+ pass
14
+
15
+
16
+ class KonectyValidationError(KonectyError):
17
+ """Raised for validation errors."""
18
+
19
+ pass
20
+
21
+
22
+ class KonectySerializationError(KonectyError):
23
+ """Raised when a value is not serializable."""
24
+
25
+ def __init__(self) -> None:
26
+ super().__init__("Tipo não serializável")
@@ -0,0 +1,22 @@
1
+ """Feature-specific types (KPI, Graph, Pivot, CrossModuleQuery, etc.). Re-exported for convenience."""
2
+
3
+ from .cross_module_query import (
4
+ CrossModuleQuery,
5
+ CrossModuleRelation,
6
+ Aggregator,
7
+ DEFAULT_PRIMARY_LIMIT,
8
+ MAX_RELATION_LIMIT,
9
+ MAX_RELATIONS,
10
+ )
11
+ from .kpi import KpiConfig, KpiOperation
12
+
13
+ __all__ = [
14
+ "Aggregator",
15
+ "CrossModuleQuery",
16
+ "CrossModuleRelation",
17
+ "DEFAULT_PRIMARY_LIMIT",
18
+ "KpiConfig",
19
+ "KpiOperation",
20
+ "MAX_RELATION_LIMIT",
21
+ "MAX_RELATIONS",
22
+ ]
@@ -0,0 +1,72 @@
1
+ """Types for cross-module query (query/json and query/sql). Mirrors Konecty crossModuleQuery schema."""
2
+
3
+ from typing import Any, Dict, List, Optional, Union
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ MAX_RELATIONS = 10
8
+ MAX_NESTING_DEPTH = 2
9
+ MAX_RELATION_LIMIT = 100_000
10
+ DEFAULT_RELATION_LIMIT = 1000
11
+ DEFAULT_PRIMARY_LIMIT = 1000
12
+
13
+ AggregatorName = str # count, countDistinct, sum, avg, min, max, first, last, push, addToSet
14
+
15
+
16
+ class Aggregator(BaseModel):
17
+ """Aggregator in a relation or root. Field required for some (e.g. countDistinct)."""
18
+
19
+ aggregator: str
20
+ field: Optional[str] = None
21
+
22
+
23
+ class SortItem(BaseModel):
24
+ """Sort item: property and direction."""
25
+
26
+ property: str
27
+ direction: str = "ASC" # ASC | DESC
28
+
29
+
30
+ class ExplicitJoinCondition(BaseModel):
31
+ """Explicit join condition: left and right keys."""
32
+
33
+ left: str
34
+ right: str
35
+
36
+
37
+ class CrossModuleRelation(BaseModel):
38
+ """Relation in a cross-module query. At least one aggregator required."""
39
+
40
+ document: str
41
+ lookup: str
42
+ on: Optional[ExplicitJoinCondition] = None
43
+ filter: Optional[Dict[str, Any]] = None
44
+ fields: Optional[str] = None
45
+ sort: Optional[Union[str, List[SortItem]]] = None
46
+ limit: int = Field(default=DEFAULT_RELATION_LIMIT, ge=1, le=MAX_RELATION_LIMIT)
47
+ start: int = Field(default=0, ge=0)
48
+ aggregators: Dict[str, Aggregator] = Field(..., min_length=1)
49
+ relations: Optional[List["CrossModuleRelation"]] = Field(default=None, max_length=MAX_RELATIONS)
50
+
51
+ model_config = {"extra": "allow"}
52
+
53
+
54
+ CrossModuleRelation.model_rebuild()
55
+
56
+
57
+ class CrossModuleQuery(BaseModel):
58
+ """Body for POST /rest/query/json. Primary document + optional relations, groupBy, aggregators."""
59
+
60
+ document: str
61
+ filter: Optional[Dict[str, Any]] = None
62
+ fields: Optional[str] = None
63
+ sort: Optional[Union[str, List[SortItem]]] = None
64
+ limit: int = Field(default=DEFAULT_PRIMARY_LIMIT, ge=1, le=MAX_RELATION_LIMIT)
65
+ start: int = Field(default=0, ge=0)
66
+ relations: List[CrossModuleRelation] = Field(default_factory=list, max_length=MAX_RELATIONS)
67
+ groupBy: List[str] = Field(default_factory=list, alias="groupBy")
68
+ aggregators: Dict[str, Aggregator] = Field(default_factory=dict)
69
+ includeTotal: bool = Field(default=True, alias="includeTotal")
70
+ includeMeta: bool = Field(default=False, alias="includeMeta")
71
+
72
+ model_config = {"extra": "allow", "populate_by_name": True}
@@ -0,0 +1,21 @@
1
+ """KPI config for aggregation API."""
2
+
3
+ from typing import Literal, Optional
4
+
5
+ from pydantic import BaseModel, model_validator
6
+
7
+
8
+ KpiOperation = Literal["count", "sum", "avg", "min", "max", "countDistinct"]
9
+
10
+
11
+ class KpiConfig(BaseModel):
12
+ """Config for KPI aggregation. Field is required for all operations except count."""
13
+
14
+ operation: KpiOperation
15
+ field: Optional[str] = None
16
+
17
+ @model_validator(mode="after")
18
+ def count_distinct_requires_field(self) -> "KpiConfig":
19
+ if self.operation == "countDistinct" and not self.field:
20
+ raise ValueError("countDistinct requires field")
21
+ return self