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,155 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any, Dict, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FilterMatch(str, Enum):
|
|
9
|
+
"""Tipo de correspondência do filtro."""
|
|
10
|
+
|
|
11
|
+
AND = "and"
|
|
12
|
+
OR = "or"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FilterOperator(str, Enum):
|
|
16
|
+
"""Operadores disponíveis para filtros."""
|
|
17
|
+
|
|
18
|
+
EQUALS = "equals"
|
|
19
|
+
NOT_EQUALS = "not_equals"
|
|
20
|
+
STARTS_WITH = "starts_with"
|
|
21
|
+
END_WITH = "end_with"
|
|
22
|
+
CONTAINS = "contains"
|
|
23
|
+
NOT_CONTAINS = "not_contains"
|
|
24
|
+
LESS_THAN = "less_than"
|
|
25
|
+
GREATER_THAN = "greater_than"
|
|
26
|
+
LESS_OR_EQUALS = "less_or_equals"
|
|
27
|
+
GREATER_OR_EQUALS = "greater_or_equals"
|
|
28
|
+
BETWEEN = "between"
|
|
29
|
+
IN = "in"
|
|
30
|
+
NOT_IN = "not_in"
|
|
31
|
+
EXISTS = "exists"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DateValue(BaseModel):
|
|
35
|
+
"""Valor de data para filtros."""
|
|
36
|
+
|
|
37
|
+
date: datetime
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BetweenValue(BaseModel):
|
|
41
|
+
"""Valor para filtros do tipo between."""
|
|
42
|
+
|
|
43
|
+
greater_or_equals: Union[int, float, DateValue, datetime, None]
|
|
44
|
+
less_or_equals: Union[int, float, DateValue, datetime, None]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FilterCondition(BaseModel):
|
|
48
|
+
"""Condição de filtro."""
|
|
49
|
+
|
|
50
|
+
term: str = Field(..., description="Campo a ser filtrado")
|
|
51
|
+
operator: FilterOperator = Field(..., description="Operador de comparação")
|
|
52
|
+
value: Any = Field(..., description="Valor para comparação")
|
|
53
|
+
disabled: bool = Field(False, description="Se a condição está desativada")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class KonectyFilter(BaseModel):
|
|
57
|
+
"""Filtro Konecty."""
|
|
58
|
+
|
|
59
|
+
match: FilterMatch = Field(FilterMatch.AND, description="Tipo de correspondência")
|
|
60
|
+
conditions: List[FilterCondition] = Field(default_factory=list, description="Lista de condições")
|
|
61
|
+
filters: List["KonectyFilter"] = Field(default_factory=list, description="Lista de filtros aninhados")
|
|
62
|
+
|
|
63
|
+
def to_json(self) -> Dict[str, Any]:
|
|
64
|
+
"""Converte o filtro para formato JSON."""
|
|
65
|
+
return self.model_dump(mode="json")
|
|
66
|
+
|
|
67
|
+
def is_empty(self) -> bool:
|
|
68
|
+
"""Verifica se o filtro está vazio."""
|
|
69
|
+
return len(self.conditions) == 0 and len(self.filters) == 0
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, data: Dict[str, Any]) -> "KonectyFilter":
|
|
73
|
+
"""Converte dicionário para filtro."""
|
|
74
|
+
return cls(**data)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def create(cls, match: Union[FilterMatch, str] = FilterMatch.AND) -> "KonectyFilter":
|
|
78
|
+
"""Cria uma nova instância de filtro.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
match: Tipo de correspondência ("and" ou "or")
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Nova instância de KonectyFilter
|
|
85
|
+
"""
|
|
86
|
+
if isinstance(match, str):
|
|
87
|
+
match = FilterMatch(match.lower())
|
|
88
|
+
return cls(match=match)
|
|
89
|
+
|
|
90
|
+
def add_condition(
|
|
91
|
+
self, term: str, operator: Union[FilterOperator, str], value: Any, disabled: bool = False
|
|
92
|
+
) -> "KonectyFilter":
|
|
93
|
+
"""Adiciona uma condição ao filtro.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
term: Campo a ser filtrado
|
|
97
|
+
operator: Operador de comparação
|
|
98
|
+
value: Valor para comparação
|
|
99
|
+
disabled: Se a condição está desativada
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Self para encadeamento
|
|
103
|
+
"""
|
|
104
|
+
if isinstance(operator, str):
|
|
105
|
+
operator = FilterOperator(operator.lower())
|
|
106
|
+
|
|
107
|
+
self.conditions.append(
|
|
108
|
+
FilterCondition(
|
|
109
|
+
term=term,
|
|
110
|
+
operator=operator,
|
|
111
|
+
value=value,
|
|
112
|
+
disabled=disabled,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def add_filter(self, match: Union[FilterMatch, str] = FilterMatch.AND) -> "KonectyFilter":
|
|
118
|
+
"""Adiciona um filtro aninhado.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
match: Tipo de correspondência do filtro aninhado
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Novo filtro aninhado
|
|
125
|
+
"""
|
|
126
|
+
if isinstance(match, str):
|
|
127
|
+
match = FilterMatch(match.lower())
|
|
128
|
+
|
|
129
|
+
nested_filter = KonectyFilter(match=match)
|
|
130
|
+
self.filters.append(nested_filter)
|
|
131
|
+
return nested_filter
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class SortDirection(str, Enum):
|
|
135
|
+
"""Direção da ordenação."""
|
|
136
|
+
|
|
137
|
+
ASC = "ASC"
|
|
138
|
+
DESC = "DESC"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SortOrder(BaseModel):
|
|
142
|
+
"""Ordenação de resultados."""
|
|
143
|
+
|
|
144
|
+
property: str = Field(..., description="Campo para ordenação")
|
|
145
|
+
direction: SortDirection = Field(..., description="Direção da ordenação")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class KonectyFindParams(BaseModel):
|
|
149
|
+
"""Parâmetros para busca no Konecty."""
|
|
150
|
+
|
|
151
|
+
filter: KonectyFilter
|
|
152
|
+
start: Optional[int] = None
|
|
153
|
+
limit: Optional[int] = None
|
|
154
|
+
sort: Optional[List[SortOrder]] = None
|
|
155
|
+
fields: Optional[List[Union[str, int]]] = None
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""HTTP helpers for Konecty API requests. Used by KonectyClient and services."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, Optional, Union
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from .exceptions import KonectyAPIError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StreamResponse:
|
|
14
|
+
"""
|
|
15
|
+
Wrapper for streaming HTTP response. Caller can read headers and iterate body by line.
|
|
16
|
+
Must be used as async context manager so the session and response are closed on exit.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__slots__ = ("_session", "_response")
|
|
20
|
+
|
|
21
|
+
def __init__(self, session: aiohttp.ClientSession, response: aiohttp.ClientResponse) -> None:
|
|
22
|
+
self._session = session
|
|
23
|
+
self._response = response
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def headers(self) -> Dict[str, str]:
|
|
27
|
+
return dict(self._response.headers)
|
|
28
|
+
|
|
29
|
+
async def readline(self) -> bytes:
|
|
30
|
+
"""Read one line (including newline). Returns empty bytes when done."""
|
|
31
|
+
return await self._response.content.readline()
|
|
32
|
+
|
|
33
|
+
async def __aenter__(self) -> "StreamResponse":
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
37
|
+
await self._response.release()
|
|
38
|
+
await self._session.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def request(
|
|
42
|
+
client: Any,
|
|
43
|
+
method: str,
|
|
44
|
+
path: str,
|
|
45
|
+
*,
|
|
46
|
+
params: Optional[Dict[str, Any]] = None,
|
|
47
|
+
json: Optional[Dict[str, Any]] = None,
|
|
48
|
+
stream: bool = False,
|
|
49
|
+
return_bytes: bool = False,
|
|
50
|
+
) -> Union[Dict[str, Any], StreamResponse, bytes]:
|
|
51
|
+
"""
|
|
52
|
+
Perform an HTTP request using the client's base_url and headers.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
client: KonectyClient (must have base_url and headers).
|
|
56
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.).
|
|
57
|
+
path: Path (e.g. /rest/data/Contact/find). Must not include base_url.
|
|
58
|
+
params: Optional query parameters.
|
|
59
|
+
json: Optional JSON body.
|
|
60
|
+
stream: If True, return StreamResponse for reading body by line; do not parse JSON.
|
|
61
|
+
return_bytes: If True (and not stream), return response body as bytes; do not parse JSON.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
If stream=True: StreamResponse (use as async with). Has .headers and .readline().
|
|
65
|
+
If return_bytes=True: bytes.
|
|
66
|
+
Else: parsed JSON dict. Raises KonectyAPIError if success is false.
|
|
67
|
+
"""
|
|
68
|
+
url = f"{client.base_url.rstrip('/')}{path}"
|
|
69
|
+
kwargs: Dict[str, Any] = {"headers": client.headers}
|
|
70
|
+
if params is not None:
|
|
71
|
+
kwargs["params"] = params
|
|
72
|
+
if json is not None:
|
|
73
|
+
kwargs["json"] = json
|
|
74
|
+
|
|
75
|
+
if stream:
|
|
76
|
+
session = aiohttp.ClientSession()
|
|
77
|
+
response = await session.request(method, url, **kwargs)
|
|
78
|
+
response.raise_for_status()
|
|
79
|
+
return StreamResponse(session, response)
|
|
80
|
+
|
|
81
|
+
async with aiohttp.ClientSession() as session:
|
|
82
|
+
async with session.request(method, url, **kwargs) as response:
|
|
83
|
+
if return_bytes:
|
|
84
|
+
response.raise_for_status()
|
|
85
|
+
return await response.read()
|
|
86
|
+
body = await response.json()
|
|
87
|
+
if response.status >= 400:
|
|
88
|
+
errors = body.get("errors", []) if isinstance(body, dict) else []
|
|
89
|
+
logger.error("API error %s: %s", response.status, errors)
|
|
90
|
+
raise KonectyAPIError(errors)
|
|
91
|
+
if isinstance(body, dict) and body.get("success") is False:
|
|
92
|
+
errors = body.get("errors", [])
|
|
93
|
+
logger.error(errors)
|
|
94
|
+
raise KonectyAPIError(errors)
|
|
95
|
+
return body
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Type, TypeVar, cast
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, create_model
|
|
4
|
+
from pydantic.fields import FieldInfo
|
|
5
|
+
|
|
6
|
+
from .types import (
|
|
7
|
+
Address,
|
|
8
|
+
KonectyDateTime,
|
|
9
|
+
KonectyEmail,
|
|
10
|
+
KonectyLookup,
|
|
11
|
+
KonectyPersonName,
|
|
12
|
+
KonectyPhone,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
ModelType = TypeVar("ModelType", bound=BaseModel)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KonectyModelGenerator:
|
|
20
|
+
def __init__(self, schema: Dict[str, Any]):
|
|
21
|
+
self.schema = schema
|
|
22
|
+
self.type_mapping: Dict[str, Type[Any]] = {
|
|
23
|
+
"json": dict,
|
|
24
|
+
"richText": str,
|
|
25
|
+
"lookup": KonectyLookup,
|
|
26
|
+
"picklist": str,
|
|
27
|
+
"url": str,
|
|
28
|
+
"boolean": bool,
|
|
29
|
+
"text": str,
|
|
30
|
+
"dateTime": KonectyDateTime,
|
|
31
|
+
"address": Address,
|
|
32
|
+
"email": KonectyEmail,
|
|
33
|
+
"phone": KonectyPhone,
|
|
34
|
+
"personName": KonectyPersonName,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def generate_model(self, language: str = "en") -> Type[BaseModel]:
|
|
38
|
+
fields: Dict[str, tuple[Type[Any], FieldInfo]] = {}
|
|
39
|
+
for field in self.schema["fields"]:
|
|
40
|
+
field_name = field["name"]
|
|
41
|
+
field_raw_type = field["type"]
|
|
42
|
+
description = (
|
|
43
|
+
field["help"].get(language, field["help"].get("en", ""))
|
|
44
|
+
if "help" in field
|
|
45
|
+
else field["label"].get(language, field["label"].get("en", ""))
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
base_type = self._get_field_type(field_raw_type)
|
|
49
|
+
current_type = base_type
|
|
50
|
+
|
|
51
|
+
if field.get("isList"):
|
|
52
|
+
current_type = List[base_type] # type: ignore
|
|
53
|
+
|
|
54
|
+
if field.get("minSelected", 1) > 1 or field.get("maxSelected", 1) > 1:
|
|
55
|
+
current_type = List[base_type] # type: ignore
|
|
56
|
+
|
|
57
|
+
is_required = (
|
|
58
|
+
field.get("isRequired", False) or field.get("minSelected", 0) > 0
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if not is_required and "defaultValue" not in field:
|
|
62
|
+
current_type = Optional[base_type] # type: ignore
|
|
63
|
+
|
|
64
|
+
field_params = {
|
|
65
|
+
"alias": field_name,
|
|
66
|
+
"description": description,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if "defaultValue" in field:
|
|
70
|
+
field_params["default"] = field["defaultValue"]
|
|
71
|
+
|
|
72
|
+
if not is_required and "defaultValue" not in field:
|
|
73
|
+
field_params["default"] = None
|
|
74
|
+
|
|
75
|
+
fields[self._clean_name_for_pydantic(field_name)] = (
|
|
76
|
+
current_type,
|
|
77
|
+
Field(**field_params),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
model_name = self.schema["name"]
|
|
81
|
+
return create_model(model_name, **fields)
|
|
82
|
+
|
|
83
|
+
def _clean_name_for_pydantic(self, name: str) -> str:
|
|
84
|
+
return name.replace("_", "")
|
|
85
|
+
|
|
86
|
+
def _get_field_type(self, field_type: str) -> Type[Any]:
|
|
87
|
+
result = self.type_mapping.get(field_type, Any)
|
|
88
|
+
return cast(Type[Any], result)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""JSON serialization helpers for Konecty API (e.g. $date format)."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .exceptions import KonectySerializationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def json_serial(obj: Any) -> Any:
|
|
10
|
+
"""Serialize objects to JSON-friendly form (e.g. datetime -> $date)."""
|
|
11
|
+
if isinstance(obj, datetime):
|
|
12
|
+
return {"$date": obj.isoformat()}
|
|
13
|
+
raise KonectySerializationError()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Aggregation API: KPI, Graph, Pivot."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, Optional, Union
|
|
5
|
+
|
|
6
|
+
from ..serialization import json_serial
|
|
7
|
+
from ..filters import KonectyFilter
|
|
8
|
+
from ..feature_types.kpi import KpiConfig
|
|
9
|
+
from .base import BaseService
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AggregationService(BaseService):
|
|
13
|
+
"""Service for KPI, Graph, and Pivot aggregations."""
|
|
14
|
+
|
|
15
|
+
async def get_kpi(
|
|
16
|
+
self,
|
|
17
|
+
module: str,
|
|
18
|
+
kpi_config: KpiConfig,
|
|
19
|
+
*,
|
|
20
|
+
filter_params: Optional[KonectyFilter] = None,
|
|
21
|
+
display_name: Optional[str] = None,
|
|
22
|
+
display_type: Optional[str] = None,
|
|
23
|
+
sort: Optional[Any] = None,
|
|
24
|
+
limit: Optional[int] = None,
|
|
25
|
+
start: Optional[int] = None,
|
|
26
|
+
with_detail_fields: Optional[bool] = None,
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Run KPI aggregation. GET /rest/data/{module}/kpi. Returns { value, count }.
|
|
30
|
+
"""
|
|
31
|
+
params: Dict[str, str] = {
|
|
32
|
+
"kpiConfig": json.dumps(kpi_config.model_dump(exclude_none=True)),
|
|
33
|
+
}
|
|
34
|
+
if filter_params is not None:
|
|
35
|
+
params["filter"] = json.dumps(filter_params.to_json(), default=json_serial)
|
|
36
|
+
if display_name is not None:
|
|
37
|
+
params["displayName"] = display_name
|
|
38
|
+
if display_type is not None:
|
|
39
|
+
params["displayType"] = display_type
|
|
40
|
+
if sort is not None:
|
|
41
|
+
params["sort"] = json.dumps(sort, default=json_serial) if not isinstance(sort, str) else sort
|
|
42
|
+
if limit is not None:
|
|
43
|
+
params["limit"] = str(limit)
|
|
44
|
+
if start is not None:
|
|
45
|
+
params["start"] = str(start)
|
|
46
|
+
if with_detail_fields is not None:
|
|
47
|
+
params["withDetailFields"] = "1" if with_detail_fields else "0"
|
|
48
|
+
path = f"/rest/data/{module}/kpi"
|
|
49
|
+
result = await self._get(path, params=params)
|
|
50
|
+
if isinstance(result, dict) and result.get("success"):
|
|
51
|
+
return {"value": result.get("value", 0), "count": result.get("count", 0)}
|
|
52
|
+
return {"value": 0, "count": 0}
|
|
53
|
+
|
|
54
|
+
async def get_graph(
|
|
55
|
+
self,
|
|
56
|
+
module: str,
|
|
57
|
+
graph_config: Union[Dict[str, Any], Any],
|
|
58
|
+
*,
|
|
59
|
+
filter_params: Optional[KonectyFilter] = None,
|
|
60
|
+
display_name: Optional[str] = None,
|
|
61
|
+
display_type: Optional[str] = None,
|
|
62
|
+
sort: Optional[Any] = None,
|
|
63
|
+
limit: Optional[int] = None,
|
|
64
|
+
start: Optional[int] = None,
|
|
65
|
+
with_detail_fields: Optional[bool] = None,
|
|
66
|
+
lang: Optional[str] = None,
|
|
67
|
+
) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Get graph as SVG. GET /rest/data/{module}/graph. graphConfig: type, xAxis, yAxis, series, etc.
|
|
70
|
+
"""
|
|
71
|
+
payload = (
|
|
72
|
+
graph_config.model_dump(by_alias=True, exclude_none=True)
|
|
73
|
+
if hasattr(graph_config, "model_dump")
|
|
74
|
+
else graph_config
|
|
75
|
+
)
|
|
76
|
+
params: Dict[str, str] = {"graphConfig": json.dumps(payload)}
|
|
77
|
+
if filter_params is not None:
|
|
78
|
+
params["filter"] = json.dumps(filter_params.to_json(), default=json_serial)
|
|
79
|
+
if display_name is not None:
|
|
80
|
+
params["displayName"] = display_name
|
|
81
|
+
if display_type is not None:
|
|
82
|
+
params["displayType"] = display_type
|
|
83
|
+
if sort is not None:
|
|
84
|
+
params["sort"] = json.dumps(sort, default=json_serial) if not isinstance(sort, str) else sort
|
|
85
|
+
if limit is not None:
|
|
86
|
+
params["limit"] = str(limit)
|
|
87
|
+
if start is not None:
|
|
88
|
+
params["start"] = str(start)
|
|
89
|
+
if with_detail_fields is not None:
|
|
90
|
+
params["withDetailFields"] = "1" if with_detail_fields else "0"
|
|
91
|
+
if lang is not None:
|
|
92
|
+
params["lang"] = lang
|
|
93
|
+
path = f"/rest/data/{module}/graph"
|
|
94
|
+
raw = await self._get(path, params=params, return_bytes=True)
|
|
95
|
+
if isinstance(raw, bytes):
|
|
96
|
+
return raw.decode("utf-8")
|
|
97
|
+
return str(raw)
|
|
98
|
+
|
|
99
|
+
async def get_pivot(
|
|
100
|
+
self,
|
|
101
|
+
module: str,
|
|
102
|
+
pivot_config: Union[Dict[str, Any], Any],
|
|
103
|
+
*,
|
|
104
|
+
filter_params: Optional[KonectyFilter] = None,
|
|
105
|
+
display_name: Optional[str] = None,
|
|
106
|
+
display_type: Optional[str] = None,
|
|
107
|
+
sort: Optional[Any] = None,
|
|
108
|
+
limit: Optional[int] = None,
|
|
109
|
+
start: Optional[int] = None,
|
|
110
|
+
with_detail_fields: Optional[bool] = None,
|
|
111
|
+
lang: Optional[str] = None,
|
|
112
|
+
) -> Dict[str, Any]:
|
|
113
|
+
"""
|
|
114
|
+
Get pivot table result. GET /rest/data/{module}/pivot. pivotConfig: rows, columns, values.
|
|
115
|
+
"""
|
|
116
|
+
payload = (
|
|
117
|
+
pivot_config.model_dump(by_alias=True, exclude_none=True)
|
|
118
|
+
if hasattr(pivot_config, "model_dump")
|
|
119
|
+
else pivot_config
|
|
120
|
+
)
|
|
121
|
+
params: Dict[str, str] = {"pivotConfig": json.dumps(payload)}
|
|
122
|
+
if filter_params is not None:
|
|
123
|
+
params["filter"] = json.dumps(filter_params.to_json(), default=json_serial)
|
|
124
|
+
if display_name is not None:
|
|
125
|
+
params["displayName"] = display_name
|
|
126
|
+
if display_type is not None:
|
|
127
|
+
params["displayType"] = display_type
|
|
128
|
+
if sort is not None:
|
|
129
|
+
params["sort"] = json.dumps(sort, default=json_serial) if not isinstance(sort, str) else sort
|
|
130
|
+
if limit is not None:
|
|
131
|
+
params["limit"] = str(limit)
|
|
132
|
+
if start is not None:
|
|
133
|
+
params["start"] = str(start)
|
|
134
|
+
if with_detail_fields is not None:
|
|
135
|
+
params["withDetailFields"] = "1" if with_detail_fields else "0"
|
|
136
|
+
if lang is not None:
|
|
137
|
+
params["lang"] = lang
|
|
138
|
+
path = f"/rest/data/{module}/pivot"
|
|
139
|
+
result = await self._get(path, params=params)
|
|
140
|
+
if isinstance(result, dict):
|
|
141
|
+
return result
|
|
142
|
+
return {}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Auth API: Google hosted authorization code flow and login options."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Union
|
|
4
|
+
from urllib.parse import quote, urlencode
|
|
5
|
+
|
|
6
|
+
from ..exceptions import KonectyAPIError
|
|
7
|
+
from .base import BaseService
|
|
8
|
+
|
|
9
|
+
GOOGLE_START_PATH = "/api/auth/google/start"
|
|
10
|
+
# Conjunto que `encodeURIComponent` deixa passar além de [A-Za-z0-9_.-~], que o
|
|
11
|
+
# `quote` já preserva por padrão. Usado para que a URL montada aqui seja
|
|
12
|
+
# byte a byte igual à do SDK TypeScript — ver `_encode_query` abaixo.
|
|
13
|
+
_ENCODE_URI_COMPONENT_SAFE = "!*'()"
|
|
14
|
+
GOOGLE_SESSION_PATH = "/api/auth/google/session"
|
|
15
|
+
LOGIN_OPTIONS_PATH = "/api/auth/login-options"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _first_error_message(error: KonectyAPIError) -> str:
|
|
19
|
+
"""Extract errors[0].message from a KonectyAPIError raised by the HTTP layer."""
|
|
20
|
+
errors = error.args[0] if error.args else None
|
|
21
|
+
if isinstance(errors, list) and errors:
|
|
22
|
+
first = errors[0]
|
|
23
|
+
if isinstance(first, dict):
|
|
24
|
+
message = first.get("message")
|
|
25
|
+
if isinstance(message, str) and message:
|
|
26
|
+
return message
|
|
27
|
+
return str(error)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _encode_query(params: list) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Codifica o query string como `encodeURIComponent` do SDK TypeScript.
|
|
33
|
+
|
|
34
|
+
O default do `urlencode` é `quote_plus`, que transforma espaço em `+`;
|
|
35
|
+
`encodeURIComponent` produz `%20`. Os dois decodificam para o mesmo valor no
|
|
36
|
+
servidor, mas a spec pede URLs idênticas entre os SDKs.
|
|
37
|
+
"""
|
|
38
|
+
return urlencode(params, quote_via=quote, safe=_ENCODE_URI_COMPONENT_SAFE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthService(BaseService):
|
|
42
|
+
"""Service for the Google login flow (authorization code hosted by Konecty)."""
|
|
43
|
+
|
|
44
|
+
def google_login_url(
|
|
45
|
+
self,
|
|
46
|
+
client_id: str,
|
|
47
|
+
redirect_uri: Optional[str] = None,
|
|
48
|
+
state: Optional[str] = None,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Build the absolute URL of GET /api/auth/google/start. Does not perform any request.
|
|
52
|
+
|
|
53
|
+
The browser follows this URL; Konecty redirects to accounts.google.com and,
|
|
54
|
+
after consent, back to redirect_uri with a single-use code (and the original state).
|
|
55
|
+
"""
|
|
56
|
+
params = [("client_id", client_id)]
|
|
57
|
+
if redirect_uri is not None:
|
|
58
|
+
params.append(("redirect_uri", redirect_uri))
|
|
59
|
+
if state is not None:
|
|
60
|
+
params.append(("state", state))
|
|
61
|
+
base_url = self._client.base_url.rstrip("/")
|
|
62
|
+
return f"{base_url}{GOOGLE_START_PATH}?{_encode_query(params)}"
|
|
63
|
+
|
|
64
|
+
async def exchange_google_code(
|
|
65
|
+
self,
|
|
66
|
+
code: str,
|
|
67
|
+
*,
|
|
68
|
+
geolocation: Optional[Union[Dict[str, Any], str]] = None,
|
|
69
|
+
resolution: Optional[Union[Dict[str, Any], str]] = None,
|
|
70
|
+
source: Optional[str] = None,
|
|
71
|
+
fingerprint: Optional[str] = None,
|
|
72
|
+
) -> Dict[str, Any]:
|
|
73
|
+
"""
|
|
74
|
+
POST /api/auth/google/session. Exchanges the single-use code (TTL 60s) for a session.
|
|
75
|
+
|
|
76
|
+
On success the returned authId is adopted by the client, so following requests
|
|
77
|
+
are authenticated. On error raises KonectyAPIError with errors[0].message and
|
|
78
|
+
leaves the client authentication state untouched.
|
|
79
|
+
"""
|
|
80
|
+
payload: Dict[str, Any] = {"code": code}
|
|
81
|
+
if geolocation is not None:
|
|
82
|
+
payload["geolocation"] = geolocation
|
|
83
|
+
if resolution is not None:
|
|
84
|
+
payload["resolution"] = resolution
|
|
85
|
+
if source is not None:
|
|
86
|
+
payload["source"] = source
|
|
87
|
+
if fingerprint is not None:
|
|
88
|
+
payload["fingerprint"] = fingerprint
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
response = await self._post(GOOGLE_SESSION_PATH, json=payload)
|
|
92
|
+
except KonectyAPIError as error:
|
|
93
|
+
raise KonectyAPIError(_first_error_message(error)) from error
|
|
94
|
+
|
|
95
|
+
auth_id = response.get("authId") if isinstance(response, dict) else None
|
|
96
|
+
if not isinstance(auth_id, str) or not auth_id:
|
|
97
|
+
raise KonectyAPIError("Google session response has no authId")
|
|
98
|
+
|
|
99
|
+
self._client.set_auth_id(auth_id)
|
|
100
|
+
return response
|
|
101
|
+
|
|
102
|
+
async def get_login_options(self) -> Dict[str, Any]:
|
|
103
|
+
"""GET /api/auth/login-options. Flags: passwordEnabled, emailOtpEnabled,
|
|
104
|
+
whatsAppOtpEnabled, webauthnEnabled, webauthnRequired, googleEnabled."""
|
|
105
|
+
return await self._get(LOGIN_OPTIONS_PATH)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Base service class. All domain services inherit from it and delegate HTTP to the client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, TypeVar
|
|
4
|
+
|
|
5
|
+
Client = TypeVar("Client", bound=Any)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseService:
|
|
9
|
+
"""Base for all Konecty API services. Exposes _get, _post, _put, _delete via client._request."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, client: Client) -> None:
|
|
12
|
+
self._client = client
|
|
13
|
+
|
|
14
|
+
async def _get(
|
|
15
|
+
self,
|
|
16
|
+
path: str,
|
|
17
|
+
*,
|
|
18
|
+
params: Optional[Dict[str, Any]] = None,
|
|
19
|
+
stream: bool = False,
|
|
20
|
+
return_bytes: bool = False,
|
|
21
|
+
) -> Any:
|
|
22
|
+
return await self._client._request(
|
|
23
|
+
"GET", path, params=params, stream=stream, return_bytes=return_bytes
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
async def _post(
|
|
27
|
+
self,
|
|
28
|
+
path: str,
|
|
29
|
+
*,
|
|
30
|
+
params: Optional[Dict[str, Any]] = None,
|
|
31
|
+
json: Optional[Dict[str, Any]] = None,
|
|
32
|
+
stream: bool = False,
|
|
33
|
+
) -> Any:
|
|
34
|
+
return await self._client._request(
|
|
35
|
+
"POST", path, params=params, json=json, stream=stream
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def _put(
|
|
39
|
+
self,
|
|
40
|
+
path: str,
|
|
41
|
+
*,
|
|
42
|
+
params: Optional[Dict[str, Any]] = None,
|
|
43
|
+
json: Optional[Dict[str, Any]] = None,
|
|
44
|
+
) -> Any:
|
|
45
|
+
return await self._client._request("PUT", path, params=params, json=json)
|
|
46
|
+
|
|
47
|
+
async def _delete(
|
|
48
|
+
self,
|
|
49
|
+
path: str,
|
|
50
|
+
*,
|
|
51
|
+
params: Optional[Dict[str, Any]] = None,
|
|
52
|
+
json: Optional[Dict[str, Any]] = None,
|
|
53
|
+
) -> Any:
|
|
54
|
+
return await self._client._request("DELETE", path, params=params, json=json)
|
|
55
|
+
|
|
56
|
+
async def _patch(
|
|
57
|
+
self,
|
|
58
|
+
path: str,
|
|
59
|
+
*,
|
|
60
|
+
params: Optional[Dict[str, Any]] = None,
|
|
61
|
+
json: Optional[Dict[str, Any]] = None,
|
|
62
|
+
) -> Any:
|
|
63
|
+
return await self._client._request("PATCH", path, params=params, json=json)
|