konecty-sdk-python 1.2.2__tar.gz → 2.0.0__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 (32) hide show
  1. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/lib/client.py +408 -24
  2. konecty_sdk_python-2.0.0/KonectySdkPython/lib/exceptions.py +26 -0
  3. konecty_sdk_python-2.0.0/KonectySdkPython/lib/feature_types/__init__.py +22 -0
  4. konecty_sdk_python-2.0.0/KonectySdkPython/lib/feature_types/cross_module_query.py +72 -0
  5. konecty_sdk_python-2.0.0/KonectySdkPython/lib/feature_types/kpi.py +21 -0
  6. konecty_sdk_python-2.0.0/KonectySdkPython/lib/http.py +95 -0
  7. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/__init__.py +5 -0
  8. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/aggregation.py +142 -0
  9. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/base.py +63 -0
  10. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/change_user.py +64 -0
  11. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/comments.py +89 -0
  12. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/export.py +54 -0
  13. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/files.py +59 -0
  14. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/notifications.py +41 -0
  15. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/query.py +190 -0
  16. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/stream.py +132 -0
  17. konecty_sdk_python-2.0.0/KonectySdkPython/lib/services/subscriptions.py +26 -0
  18. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/lib/settings.py +16 -16
  19. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/PKG-INFO +1 -1
  20. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/pyproject.toml +1 -1
  21. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/.gitignore +0 -0
  22. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/__init__.py +0 -0
  23. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/cli/__init__.py +0 -0
  24. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/cli/apply.py +0 -0
  25. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/cli/backup.py +0 -0
  26. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/cli/pull.py +0 -0
  27. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/lib/__init__.py +0 -0
  28. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/lib/file_manager.py +0 -0
  29. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/lib/filters.py +0 -0
  30. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/lib/model.py +0 -0
  31. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/KonectySdkPython/lib/types.py +0 -0
  32. {konecty_sdk_python-1.2.2 → konecty_sdk_python-2.0.0}/README.md +0 -0
@@ -3,12 +3,30 @@
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
+ KonectySerializationError,
14
+ KonectyValidationError,
15
+ )
10
16
  from .file_manager import FileManager
11
17
  from .filters import KonectyFilter, KonectyFindParams
18
+ from .http import request as _http_request
19
+ from .http import StreamResponse
20
+ from .feature_types.kpi import KpiConfig
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
12
30
  from .types import KonectyDateTime, KonectyUpdateId
13
31
 
14
32
  # Configura o logger do urllib3 para mostrar apenas erros
@@ -39,43 +57,409 @@ def get_first_dict(items: List[Any]) -> Optional[KonectyDict]:
39
57
  return None
40
58
 
41
59
 
42
- class KonectyError(Exception):
43
- """Exceção base para erros do Konecty."""
60
+ def json_serial(obj: Any) -> str:
61
+ """Serializa objetos para JSON."""
62
+ if isinstance(obj, datetime):
63
+ return {"$date": obj.isoformat()}
64
+ raise KonectySerializationError()
44
65
 
45
- pass
46
66
 
67
+ class KonectyClient:
68
+ def __init__(self, base_url: str, token: str) -> None:
69
+ self.base_url = base_url
70
+ self.headers = {"Authorization": f"{token}"}
71
+ self.file_manager = FileManager(base_url=base_url, headers=self.headers)
47
72
 
48
- class KonectyAPIError(KonectyError):
49
- """Exceção para erros da API."""
73
+ async def _request(
74
+ self,
75
+ method: str,
76
+ path: str,
77
+ *,
78
+ params: Optional[Dict[str, Any]] = None,
79
+ json: Optional[Dict[str, Any]] = None,
80
+ stream: bool = False,
81
+ return_bytes: bool = False,
82
+ ) -> Any:
83
+ """Internal HTTP request. Used by client methods and services."""
84
+ return await _http_request(
85
+ self,
86
+ method,
87
+ path,
88
+ params=params,
89
+ json=json,
90
+ stream=stream,
91
+ return_bytes=return_bytes,
92
+ )
50
93
 
51
- pass
94
+ @property
95
+ def _stream(self) -> StreamService:
96
+ if not hasattr(self, "_stream_service"):
97
+ self._stream_service = StreamService(self)
98
+ return self._stream_service
52
99
 
100
+ async def find_stream(
101
+ self,
102
+ module: str,
103
+ options: KonectyFindParams,
104
+ *,
105
+ include_total: bool = False,
106
+ ) -> FindStreamResult:
107
+ """
108
+ Stream records as NDJSON. Returns result with .stream (async generator) and .total (when include_total).
53
109
 
54
- class KonectyValidationError(KonectyError):
55
- """Exceção para erros de validação."""
110
+ Example:
111
+ result = await client.find_stream("Contact", options, include_total=True)
112
+ async for record in result.stream:
113
+ ...
114
+ total = result.total
115
+ """
116
+ return await self._stream.find_stream(module, options, include_total=include_total)
56
117
 
57
- pass
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
+ )
58
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
+ )
59
158
 
60
- class KonectySerializationError(KonectyError):
61
- """Exceção para erros de serialização."""
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
62
164
 
63
- def __init__(self) -> None:
64
- super().__init__("Tipo não serializável")
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
+ )
65
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
+ )
66
190
 
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()
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
+ )
72
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
73
209
 
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)
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(
247
+ module, data_id, comment_id, text
248
+ )
249
+
250
+ async def delete_comment(
251
+ self, module: str, data_id: str, comment_id: str
252
+ ) -> Any:
253
+ """Delete (soft) a comment."""
254
+ return await self._comments.delete_comment(module, data_id, comment_id)
255
+
256
+ async def search_comment_users(
257
+ self, module: str, data_id: str, query: str = ""
258
+ ) -> Any:
259
+ """Search users for @mention autocomplete."""
260
+ return await self._comments.search_comment_users(
261
+ module, data_id, query
262
+ )
263
+
264
+ async def search_comments(
265
+ self,
266
+ module: str,
267
+ data_id: str,
268
+ *,
269
+ query: Optional[str] = None,
270
+ author_id: Optional[str] = None,
271
+ start_date: Optional[str] = None,
272
+ end_date: Optional[str] = None,
273
+ page: Optional[int] = None,
274
+ limit: Optional[int] = None,
275
+ ) -> Any:
276
+ """Search comments with filters."""
277
+ return await self._comments.search_comments(
278
+ module,
279
+ data_id,
280
+ query=query,
281
+ author_id=author_id,
282
+ start_date=start_date,
283
+ end_date=end_date,
284
+ page=page,
285
+ limit=limit,
286
+ )
287
+
288
+ @property
289
+ def _subscriptions(self) -> SubscriptionsService:
290
+ if not hasattr(self, "_subscriptions_service"):
291
+ self._subscriptions_service = SubscriptionsService(self)
292
+ return self._subscriptions_service
293
+
294
+ async def get_subscription_status(self, module: str, data_id: str) -> Any:
295
+ """Get subscription status for a record."""
296
+ return await self._subscriptions.get_subscription_status(module, data_id)
297
+
298
+ async def subscribe(self, module: str, data_id: str) -> Any:
299
+ """Subscribe to record notifications."""
300
+ return await self._subscriptions.subscribe(module, data_id)
301
+
302
+ async def unsubscribe(self, module: str, data_id: str) -> Any:
303
+ """Unsubscribe from record."""
304
+ return await self._subscriptions.unsubscribe(module, data_id)
305
+
306
+ @property
307
+ def _notifications(self) -> NotificationsService:
308
+ if not hasattr(self, "_notifications_service"):
309
+ self._notifications_service = NotificationsService(self)
310
+ return self._notifications_service
311
+
312
+ async def list_notifications(
313
+ self,
314
+ *,
315
+ read: Optional[bool] = None,
316
+ page: Optional[int] = None,
317
+ limit: Optional[int] = None,
318
+ ) -> Any:
319
+ """List user notifications."""
320
+ return await self._notifications.list_notifications(
321
+ read=read, page=page, limit=limit
322
+ )
323
+
324
+ async def get_unread_notifications_count(self) -> Any:
325
+ """Get unread notifications count."""
326
+ return await self._notifications.get_unread_count()
327
+
328
+ async def mark_notification_read(self, notification_id: str) -> Any:
329
+ """Mark a notification as read."""
330
+ return await self._notifications.mark_notification_read(notification_id)
331
+
332
+ async def mark_all_notifications_read(self) -> Any:
333
+ """Mark all notifications as read."""
334
+ return await self._notifications.mark_all_notifications_read()
335
+
336
+ @property
337
+ def _change_user(self) -> ChangeUserService:
338
+ if not hasattr(self, "_change_user_service"):
339
+ self._change_user_service = ChangeUserService(self)
340
+ return self._change_user_service
341
+
342
+ async def change_user_add(
343
+ self, module: str, ids: List[Any], users: Any
344
+ ) -> Any:
345
+ """Add users to records."""
346
+ return await self._change_user.add_users(module, ids, users)
347
+
348
+ async def change_user_remove(
349
+ self, module: str, ids: List[Any], users: Any
350
+ ) -> Any:
351
+ """Remove users from records."""
352
+ return await self._change_user.remove_users(module, ids, users)
353
+
354
+ async def change_user_define(
355
+ self, module: str, ids: List[Any], users: Any
356
+ ) -> Any:
357
+ """Define users on records."""
358
+ return await self._change_user.define_users(module, ids, users)
359
+
360
+ async def change_user_replace(
361
+ self,
362
+ module: str,
363
+ ids: List[Any],
364
+ *,
365
+ from_user: Any = None,
366
+ to_user: Any = None,
367
+ ) -> Any:
368
+ """Replace user on records."""
369
+ return await self._change_user.replace_users(
370
+ module, ids, from_user=from_user, to_user=to_user
371
+ )
372
+
373
+ async def change_user_count_inactive(self, module: str, ids: List[Any]) -> Any:
374
+ """Count inactive users on records."""
375
+ return await self._change_user.count_inactive(module, ids)
376
+
377
+ async def change_user_remove_inactive(self, module: str, ids: List[Any]) -> Any:
378
+ """Remove inactive users from records."""
379
+ return await self._change_user.remove_inactive(module, ids)
380
+
381
+ async def change_user_set_queue(
382
+ self, module: str, ids: List[Any], queue: Any
383
+ ) -> Any:
384
+ """Set queue on records."""
385
+ return await self._change_user.set_queue(module, ids, queue)
386
+
387
+ @property
388
+ def _query(self) -> QueryService:
389
+ if not hasattr(self, "_query_service"):
390
+ self._query_service = QueryService(self)
391
+ return self._query_service
392
+
393
+ async def execute_query_json(
394
+ self,
395
+ body: Any,
396
+ *,
397
+ include_total: bool = True,
398
+ include_meta: bool = False,
399
+ ) -> QueryResult:
400
+ """Execute cross-module query (POST /rest/query/json). Returns QueryResult with .stream, .total, .meta."""
401
+ return await self._query.execute_query_json(
402
+ body, include_total=include_total, include_meta=include_meta
403
+ )
404
+
405
+ async def execute_query_sql(
406
+ self,
407
+ sql: str,
408
+ *,
409
+ include_total: bool = True,
410
+ include_meta: bool = False,
411
+ ) -> QueryResult:
412
+ """Execute SQL query (POST /rest/query/sql). Returns QueryResult. SQL parse errors return 400."""
413
+ return await self._query.execute_query_sql(
414
+ sql, include_total=include_total, include_meta=include_meta
415
+ )
416
+
417
+ async def list_saved_queries(self) -> Any:
418
+ """List saved queries."""
419
+ return await self._query.list_saved_queries()
420
+
421
+ async def get_saved_query(self, query_id: str) -> Any:
422
+ """Get a saved query by id."""
423
+ return await self._query.get_saved_query(query_id)
424
+
425
+ async def create_saved_query(
426
+ self,
427
+ name: str,
428
+ query: Dict[str, Any],
429
+ description: Optional[str] = None,
430
+ ) -> Any:
431
+ """Create a saved query."""
432
+ return await self._query.create_saved_query(
433
+ name, query, description=description
434
+ )
435
+
436
+ async def update_saved_query(
437
+ self,
438
+ query_id: str,
439
+ *,
440
+ name: Optional[str] = None,
441
+ description: Optional[str] = None,
442
+ query: Optional[Dict[str, Any]] = None,
443
+ ) -> Any:
444
+ """Update a saved query."""
445
+ return await self._query.update_saved_query(
446
+ query_id, name=name, description=description, query=query
447
+ )
448
+
449
+ async def delete_saved_query(self, query_id: str) -> Any:
450
+ """Delete a saved query."""
451
+ return await self._query.delete_saved_query(query_id)
452
+
453
+ async def share_saved_query(
454
+ self,
455
+ query_id: str,
456
+ shared_with: List[Dict[str, Any]],
457
+ is_public: Optional[bool] = None,
458
+ ) -> Any:
459
+ """Share a saved query."""
460
+ return await self._query.share_saved_query(
461
+ query_id, shared_with, is_public=is_public
462
+ )
79
463
 
80
464
  async def find(self, module: str, options: KonectyFindParams) -> List[KonectyDict]:
81
465
  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