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.
- KonectySdkPython/__init__.py +41 -0
- KonectySdkPython/cli/__init__.py +38 -0
- KonectySdkPython/cli/apply.py +555 -0
- KonectySdkPython/cli/backup.py +161 -0
- KonectySdkPython/cli/pull.py +309 -0
- KonectySdkPython/lib/__init__.py +43 -0
- KonectySdkPython/lib/client.py +950 -0
- KonectySdkPython/lib/exceptions.py +26 -0
- KonectySdkPython/lib/feature_types/__init__.py +46 -0
- KonectySdkPython/lib/feature_types/cross_module_query.py +78 -0
- KonectySdkPython/lib/feature_types/kpi.py +21 -0
- KonectySdkPython/lib/feature_types/query_json.py +242 -0
- KonectySdkPython/lib/file_manager.py +248 -0
- KonectySdkPython/lib/filters.py +155 -0
- KonectySdkPython/lib/http.py +95 -0
- KonectySdkPython/lib/model.py +88 -0
- KonectySdkPython/lib/serialization.py +13 -0
- KonectySdkPython/lib/services/__init__.py +5 -0
- KonectySdkPython/lib/services/aggregation.py +142 -0
- KonectySdkPython/lib/services/auth.py +105 -0
- KonectySdkPython/lib/services/base.py +63 -0
- KonectySdkPython/lib/services/change_user.py +64 -0
- KonectySdkPython/lib/services/comments.py +89 -0
- KonectySdkPython/lib/services/export.py +54 -0
- KonectySdkPython/lib/services/files.py +59 -0
- KonectySdkPython/lib/services/notifications.py +41 -0
- KonectySdkPython/lib/services/query.py +192 -0
- KonectySdkPython/lib/services/stream.py +133 -0
- KonectySdkPython/lib/services/subscriptions.py +26 -0
- KonectySdkPython/lib/settings.py +152 -0
- KonectySdkPython/lib/types.py +418 -0
- konecty_sdk_python-2.1.1.dist-info/METADATA +155 -0
- konecty_sdk_python-2.1.1.dist-info/RECORD +35 -0
- konecty_sdk_python-2.1.1.dist-info/WHEEL +4 -0
- konecty_sdk_python-2.1.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Change User API: add, remove, define, replace, countInactive, removeInactive, setQueue."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from .base import BaseService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChangeUserService(BaseService):
|
|
9
|
+
"""Service for changing user/queue on records."""
|
|
10
|
+
|
|
11
|
+
async def add_users(
|
|
12
|
+
self, module: str, ids: List[Any], users: Any
|
|
13
|
+
) -> Dict[str, Any]:
|
|
14
|
+
"""POST /rest/changeUser/{module}/add. Body: ids, data (users)."""
|
|
15
|
+
path = f"/rest/changeUser/{module}/add"
|
|
16
|
+
return await self._post(path, json={"ids": ids, "data": users})
|
|
17
|
+
|
|
18
|
+
async def remove_users(
|
|
19
|
+
self, module: str, ids: List[Any], users: Any
|
|
20
|
+
) -> Dict[str, Any]:
|
|
21
|
+
"""POST /rest/changeUser/{module}/remove."""
|
|
22
|
+
path = f"/rest/changeUser/{module}/remove"
|
|
23
|
+
return await self._post(path, json={"ids": ids, "data": users})
|
|
24
|
+
|
|
25
|
+
async def define_users(
|
|
26
|
+
self, module: str, ids: List[Any], users: Any
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
"""POST /rest/changeUser/{module}/define."""
|
|
29
|
+
path = f"/rest/changeUser/{module}/define"
|
|
30
|
+
return await self._post(path, json={"ids": ids, "data": users})
|
|
31
|
+
|
|
32
|
+
async def replace_users(
|
|
33
|
+
self,
|
|
34
|
+
module: str,
|
|
35
|
+
ids: List[Any],
|
|
36
|
+
*,
|
|
37
|
+
from_user: Any = None,
|
|
38
|
+
to_user: Any = None,
|
|
39
|
+
) -> Dict[str, Any]:
|
|
40
|
+
"""POST /rest/changeUser/{module}/replace. Body: ids, data: { from?, to? }."""
|
|
41
|
+
path = f"/rest/changeUser/{module}/replace"
|
|
42
|
+
data: Dict[str, Any] = {}
|
|
43
|
+
if from_user is not None:
|
|
44
|
+
data["from"] = from_user
|
|
45
|
+
if to_user is not None:
|
|
46
|
+
data["to"] = to_user
|
|
47
|
+
return await self._post(path, json={"ids": ids, "data": data})
|
|
48
|
+
|
|
49
|
+
async def count_inactive(self, module: str, ids: List[Any]) -> Dict[str, Any]:
|
|
50
|
+
"""POST /rest/changeUser/{module}/countInactive. Body: ids."""
|
|
51
|
+
path = f"/rest/changeUser/{module}/countInactive"
|
|
52
|
+
return await self._post(path, json={"ids": ids})
|
|
53
|
+
|
|
54
|
+
async def remove_inactive(self, module: str, ids: List[Any]) -> Dict[str, Any]:
|
|
55
|
+
"""POST /rest/changeUser/{module}/removeInactive. Body: ids."""
|
|
56
|
+
path = f"/rest/changeUser/{module}/removeInactive"
|
|
57
|
+
return await self._post(path, json={"ids": ids})
|
|
58
|
+
|
|
59
|
+
async def set_queue(
|
|
60
|
+
self, module: str, ids: List[Any], queue: Any
|
|
61
|
+
) -> Dict[str, Any]:
|
|
62
|
+
"""POST /rest/changeUser/{module}/setQueue. Body: ids, data (queue config)."""
|
|
63
|
+
path = f"/rest/changeUser/{module}/setQueue"
|
|
64
|
+
return await self._post(path, json={"ids": ids, "data": queue})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Comments API: list, create, update, delete, search users, search comments."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CommentsService(BaseService):
|
|
9
|
+
"""Service for record comments."""
|
|
10
|
+
|
|
11
|
+
async def get_comments(self, module: str, data_id: str) -> Dict[str, Any]:
|
|
12
|
+
"""GET /rest/comment/{module}/{data_id}. Returns API result (success, data)."""
|
|
13
|
+
path = f"/rest/comment/{module}/{data_id}"
|
|
14
|
+
return await self._get(path)
|
|
15
|
+
|
|
16
|
+
async def create_comment(
|
|
17
|
+
self,
|
|
18
|
+
module: str,
|
|
19
|
+
data_id: str,
|
|
20
|
+
text: str,
|
|
21
|
+
parent_id: Optional[str] = None,
|
|
22
|
+
) -> Dict[str, Any]:
|
|
23
|
+
"""POST /rest/comment/{module}/{data_id}. Body: text, parentId optional."""
|
|
24
|
+
path = f"/rest/comment/{module}/{data_id}"
|
|
25
|
+
body: Dict[str, Any] = {"text": text}
|
|
26
|
+
if parent_id is not None:
|
|
27
|
+
body["parentId"] = parent_id
|
|
28
|
+
return await self._post(path, json=body)
|
|
29
|
+
|
|
30
|
+
async def update_comment(
|
|
31
|
+
self,
|
|
32
|
+
module: str,
|
|
33
|
+
data_id: str,
|
|
34
|
+
comment_id: str,
|
|
35
|
+
text: str,
|
|
36
|
+
) -> Dict[str, Any]:
|
|
37
|
+
"""PUT /rest/comment/{module}/{data_id}/{comment_id}. Body: text."""
|
|
38
|
+
path = f"/rest/comment/{module}/{data_id}/{comment_id}"
|
|
39
|
+
return await self._put(path, json={"text": text})
|
|
40
|
+
|
|
41
|
+
async def delete_comment(
|
|
42
|
+
self,
|
|
43
|
+
module: str,
|
|
44
|
+
data_id: str,
|
|
45
|
+
comment_id: str,
|
|
46
|
+
) -> Dict[str, Any]:
|
|
47
|
+
"""DELETE /rest/comment/{module}/{data_id}/{comment_id}."""
|
|
48
|
+
path = f"/rest/comment/{module}/{data_id}/{comment_id}"
|
|
49
|
+
return await self._delete(path)
|
|
50
|
+
|
|
51
|
+
async def search_comment_users(
|
|
52
|
+
self,
|
|
53
|
+
module: str,
|
|
54
|
+
data_id: str,
|
|
55
|
+
query: str = "",
|
|
56
|
+
) -> Dict[str, Any]:
|
|
57
|
+
"""GET /rest/comment/{module}/{data_id}/users/search?q=."""
|
|
58
|
+
path = f"/rest/comment/{module}/{data_id}/users/search"
|
|
59
|
+
params = {"q": query} if query else {}
|
|
60
|
+
return await self._get(path, params=params or None)
|
|
61
|
+
|
|
62
|
+
async def search_comments(
|
|
63
|
+
self,
|
|
64
|
+
module: str,
|
|
65
|
+
data_id: str,
|
|
66
|
+
*,
|
|
67
|
+
query: Optional[str] = None,
|
|
68
|
+
author_id: Optional[str] = None,
|
|
69
|
+
start_date: Optional[str] = None,
|
|
70
|
+
end_date: Optional[str] = None,
|
|
71
|
+
page: Optional[int] = None,
|
|
72
|
+
limit: Optional[int] = None,
|
|
73
|
+
) -> Dict[str, Any]:
|
|
74
|
+
"""GET /rest/comment/{module}/{data_id}/search with query params."""
|
|
75
|
+
path = f"/rest/comment/{module}/{data_id}/search"
|
|
76
|
+
params: Dict[str, str] = {}
|
|
77
|
+
if query is not None:
|
|
78
|
+
params["q"] = query
|
|
79
|
+
if author_id is not None:
|
|
80
|
+
params["authorId"] = author_id
|
|
81
|
+
if start_date is not None:
|
|
82
|
+
params["startDate"] = start_date
|
|
83
|
+
if end_date is not None:
|
|
84
|
+
params["endDate"] = end_date
|
|
85
|
+
if page is not None:
|
|
86
|
+
params["page"] = str(page)
|
|
87
|
+
if limit is not None:
|
|
88
|
+
params["limit"] = str(limit)
|
|
89
|
+
return await self._get(path, params=params or None)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Export list as CSV, XLSX, or JSON (bytes)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
5
|
+
|
|
6
|
+
from ..serialization import json_serial
|
|
7
|
+
from ..filters import KonectyFilter
|
|
8
|
+
from .base import BaseService
|
|
9
|
+
|
|
10
|
+
ExportFormat = Literal["csv", "xlsx", "json", "xls"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExportService(BaseService):
|
|
14
|
+
"""Service for exporting lists (GET returns file bytes)."""
|
|
15
|
+
|
|
16
|
+
async def export_list(
|
|
17
|
+
self,
|
|
18
|
+
module: str,
|
|
19
|
+
list_name: str,
|
|
20
|
+
format: ExportFormat,
|
|
21
|
+
*,
|
|
22
|
+
filter_params: Optional[KonectyFilter] = None,
|
|
23
|
+
sort: Optional[Any] = None,
|
|
24
|
+
fields: Optional[List[Union[str, int]]] = None,
|
|
25
|
+
start: Optional[int] = None,
|
|
26
|
+
limit: Optional[int] = None,
|
|
27
|
+
display_name: Optional[str] = None,
|
|
28
|
+
display_type: Optional[str] = None,
|
|
29
|
+
) -> bytes:
|
|
30
|
+
"""
|
|
31
|
+
Export list as file. GET /rest/data/{module}/list/{list_name}/{type}.
|
|
32
|
+
type is csv, xlsx, or json (xls is normalized to xlsx).
|
|
33
|
+
Returns file content as bytes.
|
|
34
|
+
"""
|
|
35
|
+
export_type = "xlsx" if format == "xls" else format
|
|
36
|
+
if export_type not in ("csv", "xlsx", "json"):
|
|
37
|
+
raise ValueError("format must be csv, xlsx, json, or xls")
|
|
38
|
+
params: Dict[str, str] = {}
|
|
39
|
+
if filter_params is not None:
|
|
40
|
+
params["filter"] = json.dumps(filter_params.to_json(), default=json_serial)
|
|
41
|
+
if sort is not None:
|
|
42
|
+
params["sort"] = json.dumps(sort, default=json_serial) if not isinstance(sort, str) else sort
|
|
43
|
+
if fields is not None:
|
|
44
|
+
params["fields"] = ",".join(str(f) for f in fields)
|
|
45
|
+
if start is not None:
|
|
46
|
+
params["start"] = str(start)
|
|
47
|
+
if limit is not None:
|
|
48
|
+
params["limit"] = str(limit)
|
|
49
|
+
if display_name is not None:
|
|
50
|
+
params["displayName"] = display_name
|
|
51
|
+
if display_type is not None:
|
|
52
|
+
params["displayType"] = display_type
|
|
53
|
+
path = f"/rest/data/{module}/list/{list_name}/{export_type}"
|
|
54
|
+
return await self._get(path, params=params, return_bytes=True)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""File download and image download (binary responses)."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal, Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FilesService(BaseService):
|
|
9
|
+
"""Service for downloading files and images (GET returning bytes)."""
|
|
10
|
+
|
|
11
|
+
async def download_file(
|
|
12
|
+
self,
|
|
13
|
+
module: str,
|
|
14
|
+
record_code: str,
|
|
15
|
+
field_name: str,
|
|
16
|
+
file_name: str,
|
|
17
|
+
) -> bytes:
|
|
18
|
+
"""
|
|
19
|
+
Download a file from a record field. GET /rest/file/{module}/{record_code}/{field_name}/{file_name}.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
module: Document/module name.
|
|
23
|
+
record_code: Record code (id).
|
|
24
|
+
field_name: Field name holding the file.
|
|
25
|
+
file_name: File name.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
File content as bytes.
|
|
29
|
+
"""
|
|
30
|
+
path = f"/rest/file/{module}/{record_code}/{field_name}/{file_name}"
|
|
31
|
+
return await self._get(path, return_bytes=True)
|
|
32
|
+
|
|
33
|
+
async def download_image(
|
|
34
|
+
self,
|
|
35
|
+
module: str,
|
|
36
|
+
record_id: str,
|
|
37
|
+
field_name: str,
|
|
38
|
+
file_name: str,
|
|
39
|
+
*,
|
|
40
|
+
style: Optional[Literal["full", "thumb", "wm"]] = None,
|
|
41
|
+
) -> bytes:
|
|
42
|
+
"""
|
|
43
|
+
Download an image from a record field. GET /rest/image/... with optional style (full, thumb, watermark).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
module: Document/module name.
|
|
47
|
+
record_id: Record id.
|
|
48
|
+
field_name: Field name holding the image.
|
|
49
|
+
file_name: File name.
|
|
50
|
+
style: None = full file; "thumb" = thumbnail; "wm" = watermark.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Image content as bytes.
|
|
54
|
+
"""
|
|
55
|
+
if style is not None:
|
|
56
|
+
path = f"/rest/image/{style}/{module}/{record_id}/{field_name}/{file_name}"
|
|
57
|
+
else:
|
|
58
|
+
path = f"/rest/image/{module}/{record_id}/{field_name}/{file_name}"
|
|
59
|
+
return await self._get(path, return_bytes=True)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Notifications API: list, unread count, mark read, mark all read."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotificationsService(BaseService):
|
|
9
|
+
"""Service for user notifications."""
|
|
10
|
+
|
|
11
|
+
async def list_notifications(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
read: Optional[bool] = None,
|
|
15
|
+
page: Optional[int] = None,
|
|
16
|
+
limit: Optional[int] = None,
|
|
17
|
+
) -> Dict[str, Any]:
|
|
18
|
+
"""GET /rest/notifications. Query: read, page, limit."""
|
|
19
|
+
params: Dict[str, str] = {}
|
|
20
|
+
if read is not None:
|
|
21
|
+
params["read"] = "true" if read else "false"
|
|
22
|
+
if page is not None:
|
|
23
|
+
params["page"] = str(page)
|
|
24
|
+
if limit is not None:
|
|
25
|
+
params["limit"] = str(limit)
|
|
26
|
+
return await self._get(
|
|
27
|
+
"/rest/notifications", params=params if params else None
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
async def get_unread_count(self) -> Dict[str, Any]:
|
|
31
|
+
"""GET /rest/notifications/unread-count."""
|
|
32
|
+
return await self._get("/rest/notifications/unread-count")
|
|
33
|
+
|
|
34
|
+
async def mark_notification_read(self, notification_id: str) -> Dict[str, Any]:
|
|
35
|
+
"""PUT /rest/notifications/{id}/read."""
|
|
36
|
+
path = f"/rest/notifications/{notification_id}/read"
|
|
37
|
+
return await self._put(path)
|
|
38
|
+
|
|
39
|
+
async def mark_all_notifications_read(self) -> Dict[str, Any]:
|
|
40
|
+
"""PUT /rest/notifications/read-all."""
|
|
41
|
+
return await self._put("/rest/notifications/read-all")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Custom query (query/json, query/sql) and Saved Queries CRUD."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
from ..types import KonectyDict
|
|
7
|
+
from ..exceptions import KonectyAPIError
|
|
8
|
+
from ..http import StreamResponse
|
|
9
|
+
from .base import BaseService
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QueryResult:
|
|
13
|
+
"""Result of execute_query_json or execute_query_sql. Stream of records + optional total and meta."""
|
|
14
|
+
|
|
15
|
+
__slots__ = ("stream", "total", "meta")
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
stream: AsyncGenerator[KonectyDict, None],
|
|
20
|
+
total: Optional[int] = None,
|
|
21
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.stream = stream
|
|
24
|
+
self.total = total
|
|
25
|
+
self.meta = meta
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _build_query_result(
|
|
29
|
+
raw: StreamResponse,
|
|
30
|
+
include_meta: bool,
|
|
31
|
+
include_total: bool,
|
|
32
|
+
) -> QueryResult:
|
|
33
|
+
"""Read first line (maybe _meta), then return QueryResult. Generator consumes the rest."""
|
|
34
|
+
total: Optional[int] = None
|
|
35
|
+
meta: Optional[Dict[str, Any]] = None
|
|
36
|
+
if include_total:
|
|
37
|
+
tc = raw.headers.get("x-total-count")
|
|
38
|
+
if tc is not None:
|
|
39
|
+
try:
|
|
40
|
+
total = int(tc)
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
first_record: Optional[KonectyDict] = None
|
|
45
|
+
while True:
|
|
46
|
+
line = await raw.readline()
|
|
47
|
+
if not line:
|
|
48
|
+
break
|
|
49
|
+
line = line.strip()
|
|
50
|
+
if not line:
|
|
51
|
+
continue
|
|
52
|
+
obj = json.loads(line.decode("utf-8"))
|
|
53
|
+
if isinstance(obj, dict) and "_meta" in obj:
|
|
54
|
+
m = obj["_meta"]
|
|
55
|
+
if m.get("success") is False:
|
|
56
|
+
errors = m.get("errors", [])
|
|
57
|
+
raise KonectyAPIError(errors)
|
|
58
|
+
if include_meta:
|
|
59
|
+
meta = m
|
|
60
|
+
if total is None and "total" in m:
|
|
61
|
+
total = m["total"]
|
|
62
|
+
continue
|
|
63
|
+
first_record = obj
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
async def gen() -> AsyncGenerator[KonectyDict, None]:
|
|
67
|
+
if first_record is not None:
|
|
68
|
+
yield first_record
|
|
69
|
+
async with raw:
|
|
70
|
+
while True:
|
|
71
|
+
line = await raw.readline()
|
|
72
|
+
if not line:
|
|
73
|
+
break
|
|
74
|
+
line = line.strip()
|
|
75
|
+
if not line:
|
|
76
|
+
continue
|
|
77
|
+
yield json.loads(line.decode("utf-8"))
|
|
78
|
+
|
|
79
|
+
return QueryResult(stream=gen(), total=total, meta=meta)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class QueryService(BaseService):
|
|
83
|
+
"""Service for custom query (JSON/SQL) and saved queries."""
|
|
84
|
+
|
|
85
|
+
async def execute_query_json(
|
|
86
|
+
self,
|
|
87
|
+
body: Union[Dict[str, Any], Any],
|
|
88
|
+
*,
|
|
89
|
+
include_total: bool = True,
|
|
90
|
+
include_meta: bool = False,
|
|
91
|
+
) -> QueryResult:
|
|
92
|
+
"""
|
|
93
|
+
Execute a cross-module query. POST /rest/query/json. Body is CrossModuleQuery (dict or model).
|
|
94
|
+
Returns QueryResult with .stream (async generator), .total, .meta.
|
|
95
|
+
"""
|
|
96
|
+
if hasattr(body, "to_dict"):
|
|
97
|
+
payload = body.to_dict()
|
|
98
|
+
elif hasattr(body, "model_dump"):
|
|
99
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
100
|
+
else:
|
|
101
|
+
payload = dict(body)
|
|
102
|
+
path = "/rest/query/json"
|
|
103
|
+
raw = await self._post(path, json=payload, stream=True)
|
|
104
|
+
if not isinstance(raw, StreamResponse):
|
|
105
|
+
raise KonectyAPIError(["Unexpected non-stream response"])
|
|
106
|
+
return await _build_query_result(
|
|
107
|
+
raw, include_meta=include_meta, include_total=include_total
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def execute_query_sql(
|
|
111
|
+
self,
|
|
112
|
+
sql: str,
|
|
113
|
+
*,
|
|
114
|
+
include_total: bool = True,
|
|
115
|
+
include_meta: bool = False,
|
|
116
|
+
) -> QueryResult:
|
|
117
|
+
"""
|
|
118
|
+
Execute SQL (converted to cross-module query server-side). POST /rest/query/sql.
|
|
119
|
+
Returns QueryResult. On SQL parse error the server returns 400 with _meta.errors.
|
|
120
|
+
"""
|
|
121
|
+
payload: Dict[str, Any] = {"sql": sql}
|
|
122
|
+
if include_total is not None:
|
|
123
|
+
payload["includeTotal"] = include_total
|
|
124
|
+
if include_meta is not None:
|
|
125
|
+
payload["includeMeta"] = include_meta
|
|
126
|
+
path = "/rest/query/sql"
|
|
127
|
+
raw = await self._post(path, json=payload, stream=True)
|
|
128
|
+
if not isinstance(raw, StreamResponse):
|
|
129
|
+
raise KonectyAPIError(["Unexpected non-stream response"])
|
|
130
|
+
return await _build_query_result(
|
|
131
|
+
raw, include_meta=include_meta, include_total=include_total
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# --- Saved queries ---
|
|
135
|
+
|
|
136
|
+
async def list_saved_queries(self) -> Dict[str, Any]:
|
|
137
|
+
"""GET /rest/query/saved."""
|
|
138
|
+
return await self._get("/rest/query/saved")
|
|
139
|
+
|
|
140
|
+
async def get_saved_query(self, query_id: str) -> Dict[str, Any]:
|
|
141
|
+
"""GET /rest/query/saved/{id}."""
|
|
142
|
+
path = f"/rest/query/saved/{query_id}"
|
|
143
|
+
return await self._get(path)
|
|
144
|
+
|
|
145
|
+
async def create_saved_query(
|
|
146
|
+
self,
|
|
147
|
+
name: str,
|
|
148
|
+
query: Dict[str, Any],
|
|
149
|
+
description: Optional[str] = None,
|
|
150
|
+
) -> Dict[str, Any]:
|
|
151
|
+
"""POST /rest/query/saved. Body: name, query (CrossModuleQuery), description?."""
|
|
152
|
+
body: Dict[str, Any] = {"name": name, "query": query}
|
|
153
|
+
if description is not None:
|
|
154
|
+
body["description"] = description
|
|
155
|
+
return await self._post("/rest/query/saved", json=body)
|
|
156
|
+
|
|
157
|
+
async def update_saved_query(
|
|
158
|
+
self,
|
|
159
|
+
query_id: str,
|
|
160
|
+
*,
|
|
161
|
+
name: Optional[str] = None,
|
|
162
|
+
description: Optional[str] = None,
|
|
163
|
+
query: Optional[Dict[str, Any]] = None,
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""PUT /rest/query/saved/{id}. Body: name?, description?, query?."""
|
|
166
|
+
body: Dict[str, Any] = {}
|
|
167
|
+
if name is not None:
|
|
168
|
+
body["name"] = name
|
|
169
|
+
if description is not None:
|
|
170
|
+
body["description"] = description
|
|
171
|
+
if query is not None:
|
|
172
|
+
body["query"] = query
|
|
173
|
+
path = f"/rest/query/saved/{query_id}"
|
|
174
|
+
return await self._put(path, json=body)
|
|
175
|
+
|
|
176
|
+
async def delete_saved_query(self, query_id: str) -> Dict[str, Any]:
|
|
177
|
+
"""DELETE /rest/query/saved/{id}."""
|
|
178
|
+
path = f"/rest/query/saved/{query_id}"
|
|
179
|
+
return await self._delete(path)
|
|
180
|
+
|
|
181
|
+
async def share_saved_query(
|
|
182
|
+
self,
|
|
183
|
+
query_id: str,
|
|
184
|
+
shared_with: List[Dict[str, Any]],
|
|
185
|
+
is_public: Optional[bool] = None,
|
|
186
|
+
) -> Dict[str, Any]:
|
|
187
|
+
"""PATCH /rest/query/saved/{id}/share. Body: sharedWith (array of { type, _id, name }), isPublic?."""
|
|
188
|
+
body: Dict[str, Any] = {"sharedWith": shared_with}
|
|
189
|
+
if is_public is not None:
|
|
190
|
+
body["isPublic"] = is_public
|
|
191
|
+
path = f"/rest/query/saved/{query_id}/share"
|
|
192
|
+
return await self._patch(path, json=body)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Stream API: find_stream (NDJSON) and count_stream."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, AsyncGenerator, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from ..serialization import json_serial
|
|
7
|
+
from ..types import KonectyDict
|
|
8
|
+
from ..filters import KonectyFilter, KonectyFindParams
|
|
9
|
+
from ..http import StreamResponse
|
|
10
|
+
from .base import BaseService
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _find_params_to_query(options: KonectyFindParams) -> Dict[str, str]:
|
|
14
|
+
"""Build query params dict from KonectyFindParams (same as client.find)."""
|
|
15
|
+
params: Dict[str, str] = {}
|
|
16
|
+
for key, value in options.model_dump(exclude_none=True).items():
|
|
17
|
+
params[key] = (
|
|
18
|
+
json.dumps(value, default=json_serial)
|
|
19
|
+
if key != "fields"
|
|
20
|
+
else ",".join(str(f) for f in value)
|
|
21
|
+
)
|
|
22
|
+
return params
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FindStreamResult:
|
|
26
|
+
"""Result of find_stream. Has .stream (async generator of records) and .total (int | None)."""
|
|
27
|
+
|
|
28
|
+
__slots__ = ("stream", "total")
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
stream: AsyncGenerator[KonectyDict, None],
|
|
33
|
+
total: Optional[int],
|
|
34
|
+
) -> None:
|
|
35
|
+
self.stream = stream
|
|
36
|
+
self.total = total
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StreamService(BaseService):
|
|
40
|
+
"""Service for stream endpoints: findStream and count."""
|
|
41
|
+
|
|
42
|
+
async def find_stream(
|
|
43
|
+
self,
|
|
44
|
+
module: str,
|
|
45
|
+
options: KonectyFindParams,
|
|
46
|
+
*,
|
|
47
|
+
include_total: bool = False,
|
|
48
|
+
) -> FindStreamResult:
|
|
49
|
+
"""
|
|
50
|
+
Stream records as NDJSON (one JSON object per line). Uses GET /rest/stream/{module}/findStream.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
module: Document/module name.
|
|
54
|
+
options: Find params (filter, sort, limit, start, fields).
|
|
55
|
+
include_total: If True, request total count; it will be in result.total and X-Total-Count header.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
FindStreamResult with .stream (async generator of dicts) and .total (int | None when include_total).
|
|
59
|
+
"""
|
|
60
|
+
params = _find_params_to_query(options)
|
|
61
|
+
if include_total:
|
|
62
|
+
params["includeTotal"] = "1"
|
|
63
|
+
path = f"/rest/stream/{module}/findStream"
|
|
64
|
+
raw = await self._get(path, params=params, stream=True)
|
|
65
|
+
assert isinstance(raw, StreamResponse)
|
|
66
|
+
total: Optional[int] = None
|
|
67
|
+
if include_total:
|
|
68
|
+
tc = raw.headers.get("x-total-count")
|
|
69
|
+
if tc is not None:
|
|
70
|
+
try:
|
|
71
|
+
total = int(tc)
|
|
72
|
+
except ValueError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
async def gen() -> AsyncGenerator[KonectyDict, None]:
|
|
76
|
+
async with raw:
|
|
77
|
+
while True:
|
|
78
|
+
line = await raw.readline()
|
|
79
|
+
if not line:
|
|
80
|
+
break
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if not line:
|
|
83
|
+
continue
|
|
84
|
+
yield json.loads(line.decode("utf-8"))
|
|
85
|
+
|
|
86
|
+
return FindStreamResult(stream=gen(), total=total)
|
|
87
|
+
|
|
88
|
+
async def count_stream(
|
|
89
|
+
self,
|
|
90
|
+
module: str,
|
|
91
|
+
filter_params: Optional[KonectyFilter] = None,
|
|
92
|
+
*,
|
|
93
|
+
display_name: Optional[str] = None,
|
|
94
|
+
display_type: Optional[str] = None,
|
|
95
|
+
sort: Optional[Any] = None,
|
|
96
|
+
with_detail_fields: Optional[bool] = None,
|
|
97
|
+
) -> int:
|
|
98
|
+
"""
|
|
99
|
+
Return total count for the module matching the filter. GET /rest/stream/{module}/count.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
module: Document/module name.
|
|
103
|
+
filter_params: Optional filter. If None, uses empty filter (count all, subject to permissions).
|
|
104
|
+
display_name: Optional.
|
|
105
|
+
display_type: Optional.
|
|
106
|
+
sort: Optional sort (string or list).
|
|
107
|
+
with_detail_fields: Optional.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Total count.
|
|
111
|
+
"""
|
|
112
|
+
filter_obj = filter_params or KonectyFilter.create()
|
|
113
|
+
options = KonectyFindParams(filter=filter_obj)
|
|
114
|
+
params: Dict[str, str] = {}
|
|
115
|
+
for key, value in options.model_dump(exclude_none=True).items():
|
|
116
|
+
params[key] = (
|
|
117
|
+
json.dumps(value, default=json_serial)
|
|
118
|
+
if key != "fields"
|
|
119
|
+
else ",".join(str(f) for f in value)
|
|
120
|
+
)
|
|
121
|
+
if display_name is not None:
|
|
122
|
+
params["displayName"] = str(display_name)
|
|
123
|
+
if display_type is not None:
|
|
124
|
+
params["displayType"] = str(display_type)
|
|
125
|
+
if sort is not None:
|
|
126
|
+
params["sort"] = json.dumps(sort, default=json_serial) if not isinstance(sort, str) else sort
|
|
127
|
+
if with_detail_fields is not None:
|
|
128
|
+
params["withDetailFields"] = "1" if with_detail_fields else "0"
|
|
129
|
+
path = f"/rest/stream/{module}/count"
|
|
130
|
+
result = await self._get(path, params=params)
|
|
131
|
+
if isinstance(result, dict) and "total" in result:
|
|
132
|
+
return int(result["total"])
|
|
133
|
+
return 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Subscriptions API: get status, subscribe, unsubscribe."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from .base import BaseService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SubscriptionsService(BaseService):
|
|
9
|
+
"""Service for record subscription (watch) status."""
|
|
10
|
+
|
|
11
|
+
async def get_subscription_status(
|
|
12
|
+
self, module: str, data_id: str
|
|
13
|
+
) -> Dict[str, Any]:
|
|
14
|
+
"""GET /rest/subscriptions/{module}/{data_id}. Returns success and subscription data."""
|
|
15
|
+
path = f"/rest/subscriptions/{module}/{data_id}"
|
|
16
|
+
return await self._get(path)
|
|
17
|
+
|
|
18
|
+
async def subscribe(self, module: str, data_id: str) -> Dict[str, Any]:
|
|
19
|
+
"""POST /rest/subscriptions/{module}/{data_id}. Subscribe to record notifications."""
|
|
20
|
+
path = f"/rest/subscriptions/{module}/{data_id}"
|
|
21
|
+
return await self._post(path)
|
|
22
|
+
|
|
23
|
+
async def unsubscribe(self, module: str, data_id: str) -> Dict[str, Any]:
|
|
24
|
+
"""DELETE /rest/subscriptions/{module}/{data_id}."""
|
|
25
|
+
path = f"/rest/subscriptions/{module}/{data_id}"
|
|
26
|
+
return await self._delete(path)
|