megaplan-sdk 0.1.0__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.
- megaplan_sdk/__init__.py +67 -0
- megaplan_sdk/auth.py +185 -0
- megaplan_sdk/cache.py +192 -0
- megaplan_sdk/client.py +201 -0
- megaplan_sdk/constants.py +16 -0
- megaplan_sdk/exceptions.py +180 -0
- megaplan_sdk/helpers.py +108 -0
- megaplan_sdk/http_client.py +390 -0
- megaplan_sdk/logging_config.py +53 -0
- megaplan_sdk/models/__init__.py +22 -0
- megaplan_sdk/models/base.py +16 -0
- megaplan_sdk/models/comment.py +58 -0
- megaplan_sdk/models/common.py +107 -0
- megaplan_sdk/models/contractor.py +137 -0
- megaplan_sdk/models/deal.py +96 -0
- megaplan_sdk/models/department.py +40 -0
- megaplan_sdk/models/employee.py +117 -0
- megaplan_sdk/models/project.py +76 -0
- megaplan_sdk/models/task.py +75 -0
- megaplan_sdk/resources/__init__.py +15 -0
- megaplan_sdk/resources/auth.py +73 -0
- megaplan_sdk/resources/base.py +794 -0
- megaplan_sdk/resources/comments.py +148 -0
- megaplan_sdk/resources/contractors.py +173 -0
- megaplan_sdk/resources/deals.py +625 -0
- megaplan_sdk/resources/departments.py +70 -0
- megaplan_sdk/resources/employees.py +216 -0
- megaplan_sdk/resources/full_details.py +143 -0
- megaplan_sdk/resources/projects.py +854 -0
- megaplan_sdk/resources/tasks.py +932 -0
- megaplan_sdk/types.py +56 -0
- megaplan_sdk-0.1.0.dist-info/METADATA +1383 -0
- megaplan_sdk-0.1.0.dist-info/RECORD +36 -0
- megaplan_sdk-0.1.0.dist-info/WHEEL +5 -0
- megaplan_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- megaplan_sdk-0.1.0.dist-info/top_level.txt +1 -0
megaplan_sdk/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Megaplan Python SDK - Professional SDK for Megaplan API v3."""
|
|
2
|
+
|
|
3
|
+
from megaplan_sdk.client import MegaplanClient
|
|
4
|
+
from megaplan_sdk.exceptions import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
AuthorizationError,
|
|
7
|
+
MegaplanError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
ServerError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from megaplan_sdk.helpers import (
|
|
14
|
+
make_contractor_entity,
|
|
15
|
+
make_deal_entity,
|
|
16
|
+
make_employee_entity,
|
|
17
|
+
make_entity,
|
|
18
|
+
make_project_entity,
|
|
19
|
+
make_task_entity,
|
|
20
|
+
)
|
|
21
|
+
from megaplan_sdk.logging_config import setup_logging
|
|
22
|
+
from megaplan_sdk.models.comment import Comment
|
|
23
|
+
from megaplan_sdk.models.common import DateTime
|
|
24
|
+
from megaplan_sdk.models.contractor import Contractor, ContractorCompany, ContractorHuman
|
|
25
|
+
from megaplan_sdk.models.deal import Deal, DealFullDetails
|
|
26
|
+
from megaplan_sdk.models.department import Department
|
|
27
|
+
from megaplan_sdk.models.employee import Employee
|
|
28
|
+
from megaplan_sdk.models.project import Project, ProjectFullDetails
|
|
29
|
+
from megaplan_sdk.models.task import Task, TaskFullDetails
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
# Client
|
|
33
|
+
"MegaplanClient",
|
|
34
|
+
# Exceptions
|
|
35
|
+
"MegaplanError",
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"AuthorizationError",
|
|
38
|
+
"NotFoundError",
|
|
39
|
+
"ValidationError",
|
|
40
|
+
"RateLimitError",
|
|
41
|
+
"ServerError",
|
|
42
|
+
# Models
|
|
43
|
+
"Task",
|
|
44
|
+
"TaskFullDetails",
|
|
45
|
+
"Project",
|
|
46
|
+
"ProjectFullDetails",
|
|
47
|
+
"Deal",
|
|
48
|
+
"DealFullDetails",
|
|
49
|
+
"Comment",
|
|
50
|
+
"Contractor",
|
|
51
|
+
"ContractorCompany",
|
|
52
|
+
"ContractorHuman",
|
|
53
|
+
"Employee",
|
|
54
|
+
"Department",
|
|
55
|
+
"DateTime",
|
|
56
|
+
# Utils
|
|
57
|
+
"setup_logging",
|
|
58
|
+
# Helpers
|
|
59
|
+
"make_entity",
|
|
60
|
+
"make_employee_entity",
|
|
61
|
+
"make_project_entity",
|
|
62
|
+
"make_task_entity",
|
|
63
|
+
"make_deal_entity",
|
|
64
|
+
"make_contractor_entity",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
__version__ = "0.1.0"
|
megaplan_sdk/auth.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""OAuth2 authentication for Megaplan API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from megaplan_sdk.exceptions import AuthenticationError
|
|
10
|
+
from megaplan_sdk.http_client import HTTPClient
|
|
11
|
+
from megaplan_sdk.logging_config import logger
|
|
12
|
+
from megaplan_sdk.types import AuthTokenResponse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthManager:
|
|
16
|
+
"""Manages OAuth2 authentication and token lifecycle.
|
|
17
|
+
|
|
18
|
+
Handles token acquisition, refresh, and expiration tracking.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, http_client: HTTPClient) -> None:
|
|
22
|
+
"""Initialize auth manager.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
http_client: HTTP client for making requests.
|
|
26
|
+
"""
|
|
27
|
+
self._http = http_client
|
|
28
|
+
self._access_token: str | None = None
|
|
29
|
+
self._refresh_token: str | None = None
|
|
30
|
+
self._expires_at: float | None = None
|
|
31
|
+
self._refresh_lock = asyncio.Lock()
|
|
32
|
+
|
|
33
|
+
async def authenticate(self, username: str, password: str) -> str:
|
|
34
|
+
"""Authenticate with username and password.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
username: User email or username.
|
|
38
|
+
password: User password.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Access token.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
AuthenticationError: If authentication fails.
|
|
45
|
+
"""
|
|
46
|
+
logger.info(f"Authenticating user: {username}")
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
response = await self._http.post_form(
|
|
50
|
+
f"{self._http.base_url}/api/v3/auth/access_token",
|
|
51
|
+
data={
|
|
52
|
+
"username": username,
|
|
53
|
+
"password": password,
|
|
54
|
+
"grant_type": "password",
|
|
55
|
+
},
|
|
56
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
token_data: AuthTokenResponse = response.json()
|
|
61
|
+
|
|
62
|
+
self._access_token = token_data["access_token"]
|
|
63
|
+
self._refresh_token = token_data.get("refresh_token")
|
|
64
|
+
expires_in = token_data.get("expires_in", 172800)
|
|
65
|
+
self._expires_at = time.time() + expires_in
|
|
66
|
+
|
|
67
|
+
self._http.set_access_token(self._access_token)
|
|
68
|
+
|
|
69
|
+
logger.info(f"Authentication successful for user: {username}")
|
|
70
|
+
|
|
71
|
+
return self._access_token
|
|
72
|
+
|
|
73
|
+
except (httpx.HTTPError, httpx.TimeoutException, httpx.RequestError) as e:
|
|
74
|
+
logger.error(f"Authentication network error for {username}: {str(e)}")
|
|
75
|
+
raise AuthenticationError(f"Authentication failed: {str(e)}") from e
|
|
76
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
77
|
+
logger.error(f"Authentication response parsing error for {username}: {str(e)}")
|
|
78
|
+
raise AuthenticationError(f"Invalid authentication response: {str(e)}") from e
|
|
79
|
+
|
|
80
|
+
async def refresh(self, refresh_token: str | None = None) -> str:
|
|
81
|
+
"""Refresh access token.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
refresh_token: Optional refresh token. Uses stored token if not provided.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
New access token.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
AuthenticationError: If refresh fails.
|
|
91
|
+
"""
|
|
92
|
+
token = refresh_token or self._refresh_token
|
|
93
|
+
if not token:
|
|
94
|
+
logger.error("No refresh token available for token refresh")
|
|
95
|
+
raise AuthenticationError("No refresh token available")
|
|
96
|
+
|
|
97
|
+
logger.info("Refreshing access token")
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
response = await self._http.post_form(
|
|
101
|
+
f"{self._http.base_url}/api/v3/auth/access_token",
|
|
102
|
+
data={
|
|
103
|
+
"refresh_token": token,
|
|
104
|
+
"grant_type": "refresh_token",
|
|
105
|
+
},
|
|
106
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
token_data: AuthTokenResponse = response.json()
|
|
111
|
+
|
|
112
|
+
self._access_token = token_data["access_token"]
|
|
113
|
+
self._refresh_token = token_data.get("refresh_token")
|
|
114
|
+
expires_in = token_data.get("expires_in", 172800)
|
|
115
|
+
self._expires_at = time.time() + expires_in
|
|
116
|
+
|
|
117
|
+
self._http.set_access_token(self._access_token)
|
|
118
|
+
|
|
119
|
+
logger.info("Token refresh successful")
|
|
120
|
+
|
|
121
|
+
return self._access_token
|
|
122
|
+
|
|
123
|
+
except (httpx.HTTPError, httpx.TimeoutException, httpx.RequestError) as e:
|
|
124
|
+
logger.error(f"Token refresh network error: {str(e)}")
|
|
125
|
+
raise AuthenticationError(f"Token refresh failed: {str(e)}") from e
|
|
126
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
127
|
+
logger.error(f"Token refresh response parsing error: {str(e)}")
|
|
128
|
+
raise AuthenticationError(f"Invalid token refresh response: {str(e)}") from e
|
|
129
|
+
|
|
130
|
+
def get_access_token(self) -> str | None:
|
|
131
|
+
"""Get current access token.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Access token or None if not authenticated.
|
|
135
|
+
"""
|
|
136
|
+
return self._access_token
|
|
137
|
+
|
|
138
|
+
def is_token_expired(self, buffer_seconds: int = 60) -> bool:
|
|
139
|
+
"""Check if token is expired or will expire soon.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
buffer_seconds: Seconds before expiration to consider token expired.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True if token is expired or will expire within buffer.
|
|
146
|
+
"""
|
|
147
|
+
if not self._expires_at:
|
|
148
|
+
return True
|
|
149
|
+
return time.time() >= (self._expires_at - buffer_seconds)
|
|
150
|
+
|
|
151
|
+
async def ensure_authenticated(self, username: str, password: str) -> str:
|
|
152
|
+
"""Ensure we have a valid access token.
|
|
153
|
+
|
|
154
|
+
Authenticates if needed or refreshes token if expired.
|
|
155
|
+
Uses lock to prevent race conditions when multiple requests
|
|
156
|
+
try to refresh token simultaneously.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
username: Username for authentication.
|
|
160
|
+
password: Password for authentication.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Valid access token.
|
|
164
|
+
"""
|
|
165
|
+
if not self._access_token or self.is_token_expired():
|
|
166
|
+
async with self._refresh_lock:
|
|
167
|
+
# Double-check after acquiring lock
|
|
168
|
+
# Another coroutine might have already refreshed the token
|
|
169
|
+
if not self._access_token or self.is_token_expired():
|
|
170
|
+
if self._refresh_token and not self.is_token_expired(buffer_seconds=3600):
|
|
171
|
+
try:
|
|
172
|
+
return await self.refresh()
|
|
173
|
+
except AuthenticationError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
return await self.authenticate(username, password)
|
|
177
|
+
|
|
178
|
+
return self._access_token
|
|
179
|
+
|
|
180
|
+
def clear_tokens(self) -> None:
|
|
181
|
+
"""Clear stored tokens."""
|
|
182
|
+
self._access_token = None
|
|
183
|
+
self._refresh_token = None
|
|
184
|
+
self._expires_at = None
|
|
185
|
+
self._http.set_access_token(None)
|
megaplan_sdk/cache.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Entity caching system for Megaplan SDK."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from time import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CacheEntry:
|
|
10
|
+
"""Cache entry with data and expiration time.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
data: Cached entity data (dict or model).
|
|
14
|
+
expires_at: Expiration timestamp (seconds since epoch).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
data: Any
|
|
18
|
+
expires_at: float
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EntityCache:
|
|
22
|
+
"""LRU cache for entities with TTL.
|
|
23
|
+
|
|
24
|
+
Thread-safe cache with automatic expiration and size limits.
|
|
25
|
+
Uses (contentType, id) as key for entity storage.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
max_size: Maximum number of entities to cache.
|
|
29
|
+
ttl: Time-to-live in seconds for cached entities.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
>>> cache = EntityCache(max_size=1000, ttl=300)
|
|
33
|
+
>>> cache.set("Employee", 123, {"id": 123, "name": "John"})
|
|
34
|
+
>>> data = cache.get("Employee", 123)
|
|
35
|
+
>>> print(data)
|
|
36
|
+
{'id': 123, 'name': 'John'}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, max_size: int = 1000, ttl: int = 300) -> None:
|
|
40
|
+
"""Initialize entity cache.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
max_size: Maximum cache size (default: 1000 entities).
|
|
44
|
+
ttl: Time-to-live in seconds (default: 300 = 5 minutes).
|
|
45
|
+
"""
|
|
46
|
+
self._cache: dict[tuple[str, int], CacheEntry] = {}
|
|
47
|
+
self._max_size = max_size
|
|
48
|
+
self._ttl = ttl
|
|
49
|
+
self._access_order: list[tuple[str, int]] = [] # For LRU
|
|
50
|
+
|
|
51
|
+
def get(self, content_type: str, entity_id: int) -> Any | None:
|
|
52
|
+
"""Get entity from cache if not expired.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
content_type: Entity content type (e.g., "Employee").
|
|
56
|
+
entity_id: Entity identifier.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Cached entity data or None if not found/expired.
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
>>> cache = EntityCache()
|
|
63
|
+
>>> cache.set("Employee", 123, {"name": "John"})
|
|
64
|
+
>>> data = cache.get("Employee", 123)
|
|
65
|
+
>>> print(data)
|
|
66
|
+
{'name': 'John'}
|
|
67
|
+
"""
|
|
68
|
+
key = (content_type, entity_id)
|
|
69
|
+
entry = self._cache.get(key)
|
|
70
|
+
|
|
71
|
+
if entry is None:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Check expiration
|
|
75
|
+
if time() > entry.expires_at:
|
|
76
|
+
# Expired - remove from cache
|
|
77
|
+
self._cache.pop(key, None)
|
|
78
|
+
if key in self._access_order:
|
|
79
|
+
self._access_order.remove(key)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
# Update access order (LRU)
|
|
83
|
+
if key in self._access_order:
|
|
84
|
+
self._access_order.remove(key)
|
|
85
|
+
self._access_order.append(key)
|
|
86
|
+
|
|
87
|
+
return entry.data
|
|
88
|
+
|
|
89
|
+
def set(self, content_type: str, entity_id: int, data: Any) -> None:
|
|
90
|
+
"""Add entity to cache with expiration.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
content_type: Entity content type (e.g., "Employee").
|
|
94
|
+
entity_id: Entity identifier.
|
|
95
|
+
data: Entity data to cache.
|
|
96
|
+
|
|
97
|
+
Examples:
|
|
98
|
+
>>> cache = EntityCache()
|
|
99
|
+
>>> cache.set("Employee", 123, {"id": 123, "name": "John"})
|
|
100
|
+
"""
|
|
101
|
+
key = (content_type, entity_id)
|
|
102
|
+
|
|
103
|
+
# Evict oldest if at capacity
|
|
104
|
+
if len(self._cache) >= self._max_size and key not in self._cache:
|
|
105
|
+
if self._access_order:
|
|
106
|
+
oldest_key = self._access_order.pop(0)
|
|
107
|
+
self._cache.pop(oldest_key, None)
|
|
108
|
+
|
|
109
|
+
# Store entry
|
|
110
|
+
self._cache[key] = CacheEntry(
|
|
111
|
+
data=data,
|
|
112
|
+
expires_at=time() + self._ttl,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Update access order
|
|
116
|
+
if key in self._access_order:
|
|
117
|
+
self._access_order.remove(key)
|
|
118
|
+
self._access_order.append(key)
|
|
119
|
+
|
|
120
|
+
def clear(self) -> None:
|
|
121
|
+
"""Clear all cached entities.
|
|
122
|
+
|
|
123
|
+
Examples:
|
|
124
|
+
>>> cache = EntityCache()
|
|
125
|
+
>>> cache.set("Employee", 1, {"id": 1})
|
|
126
|
+
>>> cache.clear()
|
|
127
|
+
>>> assert cache.get("Employee", 1) is None
|
|
128
|
+
"""
|
|
129
|
+
self._cache.clear()
|
|
130
|
+
self._access_order.clear()
|
|
131
|
+
|
|
132
|
+
def clear_type(self, content_type: str) -> None:
|
|
133
|
+
"""Clear cache for specific entity type.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
content_type: Entity type to clear (e.g., "Employee").
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
>>> cache = EntityCache()
|
|
140
|
+
>>> cache.set("Employee", 1, {"id": 1})
|
|
141
|
+
>>> cache.set("Task", 2, {"id": 2})
|
|
142
|
+
>>> cache.clear_type("Employee")
|
|
143
|
+
>>> assert cache.get("Employee", 1) is None
|
|
144
|
+
>>> assert cache.get("Task", 2) is not None
|
|
145
|
+
"""
|
|
146
|
+
keys_to_remove = [key for key in self._cache.keys() if key[0] == content_type]
|
|
147
|
+
|
|
148
|
+
for key in keys_to_remove:
|
|
149
|
+
self._cache.pop(key, None)
|
|
150
|
+
if key in self._access_order:
|
|
151
|
+
self._access_order.remove(key)
|
|
152
|
+
|
|
153
|
+
def size(self) -> int:
|
|
154
|
+
"""Get current cache size.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Number of cached entities.
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
>>> cache = EntityCache()
|
|
161
|
+
>>> cache.set("Employee", 1, {"id": 1})
|
|
162
|
+
>>> cache.set("Employee", 2, {"id": 2})
|
|
163
|
+
>>> assert cache.size() == 2
|
|
164
|
+
"""
|
|
165
|
+
return len(self._cache)
|
|
166
|
+
|
|
167
|
+
def stats(self) -> dict[str, Any]:
|
|
168
|
+
"""Get cache statistics.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Dict with cache stats (size, types, etc.).
|
|
172
|
+
|
|
173
|
+
Examples:
|
|
174
|
+
>>> cache = EntityCache()
|
|
175
|
+
>>> cache.set("Employee", 1, {"id": 1})
|
|
176
|
+
>>> cache.set("Task", 2, {"id": 2})
|
|
177
|
+
>>> stats = cache.stats()
|
|
178
|
+
>>> print(stats["size"])
|
|
179
|
+
2
|
|
180
|
+
>>> print(stats["types"])
|
|
181
|
+
{'Employee': 1, 'Task': 1}
|
|
182
|
+
"""
|
|
183
|
+
type_counts: dict[str, int] = {}
|
|
184
|
+
for content_type, _ in self._cache.keys():
|
|
185
|
+
type_counts[content_type] = type_counts.get(content_type, 0) + 1
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"size": len(self._cache),
|
|
189
|
+
"max_size": self._max_size,
|
|
190
|
+
"ttl": self._ttl,
|
|
191
|
+
"types": type_counts,
|
|
192
|
+
}
|
megaplan_sdk/client.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Main client for Megaplan SDK."""
|
|
2
|
+
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
|
|
5
|
+
from megaplan_sdk.auth import AuthManager
|
|
6
|
+
from megaplan_sdk.cache import EntityCache
|
|
7
|
+
from megaplan_sdk.http_client import HTTPClient
|
|
8
|
+
from megaplan_sdk.logging_config import logger, setup_logging
|
|
9
|
+
from megaplan_sdk.resources.auth import AuthResource
|
|
10
|
+
from megaplan_sdk.resources.comments import CommentsResource
|
|
11
|
+
from megaplan_sdk.resources.contractors import ContractorsResource
|
|
12
|
+
from megaplan_sdk.resources.deals import DealsResource
|
|
13
|
+
from megaplan_sdk.resources.departments import DepartmentsResource
|
|
14
|
+
from megaplan_sdk.resources.employees import EmployeesResource
|
|
15
|
+
from megaplan_sdk.resources.projects import ProjectsResource
|
|
16
|
+
from megaplan_sdk.resources.tasks import TasksResource
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MegaplanClient:
|
|
20
|
+
"""Main client for Megaplan API.
|
|
21
|
+
|
|
22
|
+
Coordinates all resources and handles authentication.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
base_url: str,
|
|
28
|
+
username: str | None = None,
|
|
29
|
+
password: str | None = None,
|
|
30
|
+
access_token: str | None = None,
|
|
31
|
+
timeout: float = 30.0,
|
|
32
|
+
max_retries: int = 3,
|
|
33
|
+
allow_http: bool = False,
|
|
34
|
+
log_level: str = "WARNING",
|
|
35
|
+
enable_cache: bool = True,
|
|
36
|
+
cache_ttl: int = 300,
|
|
37
|
+
cache_max_size: int = 1000,
|
|
38
|
+
default_comments_limit: int | None = None,
|
|
39
|
+
default_history_limit: int | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Initialize Megaplan client.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
base_url: Base URL for Megaplan API (e.g., https://example.megaplan.ru).
|
|
45
|
+
username: Username for authentication (optional if access_token provided).
|
|
46
|
+
password: Password for authentication (optional if access_token provided).
|
|
47
|
+
Note: Password is NOT stored in memory for security reasons.
|
|
48
|
+
access_token: Pre-obtained access token (optional).
|
|
49
|
+
timeout: Request timeout in seconds.
|
|
50
|
+
max_retries: Maximum number of retry attempts for 5xx errors.
|
|
51
|
+
allow_http: Allow HTTP connections (insecure, only for dev/test).
|
|
52
|
+
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
|
53
|
+
enable_cache: Enable entity caching (default: True).
|
|
54
|
+
cache_ttl: Cache time-to-live in seconds (default: 300 = 5 minutes).
|
|
55
|
+
cache_max_size: Maximum number of cached entities (default: 1000).
|
|
56
|
+
default_comments_limit: Default limit for comments in get_full_details().
|
|
57
|
+
None = use Megaplan API default (no explicit limit).
|
|
58
|
+
This value is used only if comments_limit is not specified in method call.
|
|
59
|
+
default_history_limit: Default limit for history in get_full_details().
|
|
60
|
+
None = use Megaplan API default (no explicit limit).
|
|
61
|
+
This value is used only if history_limit is not specified in method call.
|
|
62
|
+
|
|
63
|
+
Security Note:
|
|
64
|
+
For production use, it's recommended to use refresh tokens or pre-obtained
|
|
65
|
+
access_token instead of username/password authentication.
|
|
66
|
+
"""
|
|
67
|
+
# Setup logging
|
|
68
|
+
setup_logging(log_level)
|
|
69
|
+
logger.info(f"Initializing MegaplanClient for {base_url}")
|
|
70
|
+
|
|
71
|
+
self.base_url = base_url
|
|
72
|
+
self.username = username
|
|
73
|
+
# Security: Do NOT store password in plain text
|
|
74
|
+
self._http = HTTPClient(
|
|
75
|
+
base_url,
|
|
76
|
+
access_token=access_token,
|
|
77
|
+
timeout=timeout,
|
|
78
|
+
max_retries=max_retries,
|
|
79
|
+
allow_http=allow_http,
|
|
80
|
+
)
|
|
81
|
+
self._auth_manager = AuthManager(self._http)
|
|
82
|
+
|
|
83
|
+
# Initialize entity cache
|
|
84
|
+
self._cache = EntityCache(max_size=cache_max_size, ttl=cache_ttl) if enable_cache else None
|
|
85
|
+
if self._cache:
|
|
86
|
+
logger.debug(f"Entity cache enabled (max_size={cache_max_size}, ttl={cache_ttl}s)")
|
|
87
|
+
|
|
88
|
+
if access_token:
|
|
89
|
+
self._auth_manager._access_token = access_token
|
|
90
|
+
self._auth_manager._expires_at = None
|
|
91
|
+
logger.debug("MegaplanClient initialized with access_token")
|
|
92
|
+
|
|
93
|
+
self.auth = AuthResource(self._http, cache=self._cache)
|
|
94
|
+
self.tasks = TasksResource(
|
|
95
|
+
self._http,
|
|
96
|
+
cache=self._cache,
|
|
97
|
+
default_comments_limit=default_comments_limit,
|
|
98
|
+
default_history_limit=default_history_limit,
|
|
99
|
+
)
|
|
100
|
+
self.projects = ProjectsResource(
|
|
101
|
+
self._http,
|
|
102
|
+
cache=self._cache,
|
|
103
|
+
default_comments_limit=default_comments_limit,
|
|
104
|
+
default_history_limit=default_history_limit,
|
|
105
|
+
)
|
|
106
|
+
self.deals = DealsResource(
|
|
107
|
+
self._http,
|
|
108
|
+
cache=self._cache,
|
|
109
|
+
default_comments_limit=default_comments_limit,
|
|
110
|
+
default_history_limit=default_history_limit,
|
|
111
|
+
)
|
|
112
|
+
self.comments = CommentsResource(self._http, cache=self._cache)
|
|
113
|
+
self.contractors = ContractorsResource(self._http, cache=self._cache)
|
|
114
|
+
self.employees = EmployeesResource(self._http, cache=self._cache)
|
|
115
|
+
self.departments = DepartmentsResource(self._http, cache=self._cache)
|
|
116
|
+
|
|
117
|
+
# Security: Store password only for initial authentication if provided
|
|
118
|
+
self._initial_password = password if (username and password) else None
|
|
119
|
+
if self._initial_password:
|
|
120
|
+
logger.debug("MegaplanClient initialized with username/password")
|
|
121
|
+
|
|
122
|
+
async def __aenter__(self) -> "MegaplanClient":
|
|
123
|
+
"""Async context manager entry."""
|
|
124
|
+
logger.debug("Entering MegaplanClient context")
|
|
125
|
+
await self._http._ensure_client()
|
|
126
|
+
|
|
127
|
+
# Perform initial authentication if credentials provided
|
|
128
|
+
if self._initial_password and self.username:
|
|
129
|
+
await self._auth_manager.authenticate(self.username, self._initial_password)
|
|
130
|
+
# Clear password from memory after first use
|
|
131
|
+
self._initial_password = None
|
|
132
|
+
|
|
133
|
+
logger.debug("MegaplanClient context ready")
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
async def __aexit__(
|
|
137
|
+
self,
|
|
138
|
+
exc_type: type[BaseException] | None,
|
|
139
|
+
exc_val: BaseException | None,
|
|
140
|
+
exc_tb: TracebackType | None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Async context manager exit."""
|
|
143
|
+
await self.close()
|
|
144
|
+
|
|
145
|
+
async def authenticate(self, username: str, password: str) -> str:
|
|
146
|
+
"""Manually authenticate with username and password.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
username: User email or username.
|
|
150
|
+
password: User password.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Access token.
|
|
154
|
+
|
|
155
|
+
Note:
|
|
156
|
+
For security, password is not stored. Use refresh tokens for
|
|
157
|
+
subsequent authentications.
|
|
158
|
+
"""
|
|
159
|
+
return await self._auth_manager.authenticate(username, password)
|
|
160
|
+
|
|
161
|
+
async def close(self) -> None:
|
|
162
|
+
"""Close client and cleanup resources."""
|
|
163
|
+
logger.debug("Closing MegaplanClient")
|
|
164
|
+
await self._http.close()
|
|
165
|
+
logger.debug("MegaplanClient closed")
|
|
166
|
+
|
|
167
|
+
def set_access_token(self, access_token: str) -> None:
|
|
168
|
+
"""Set access token manually.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
access_token: OAuth2 access token.
|
|
172
|
+
"""
|
|
173
|
+
self._http.set_access_token(access_token)
|
|
174
|
+
self._auth_manager._access_token = access_token
|
|
175
|
+
|
|
176
|
+
def clear_cache(self) -> None:
|
|
177
|
+
"""Clear all cached entities.
|
|
178
|
+
|
|
179
|
+
Examples:
|
|
180
|
+
>>> async with MegaplanClient(...) as client:
|
|
181
|
+
... await client.tasks.list() # Caches employees
|
|
182
|
+
... client.clear_cache() # Clear all cache
|
|
183
|
+
"""
|
|
184
|
+
if self._cache:
|
|
185
|
+
self._cache.clear()
|
|
186
|
+
logger.debug("Entity cache cleared")
|
|
187
|
+
|
|
188
|
+
def clear_cache_type(self, content_type: str) -> None:
|
|
189
|
+
"""Clear cache for specific entity type.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
content_type: Entity type to clear (e.g., "Employee", "Contractor").
|
|
193
|
+
|
|
194
|
+
Examples:
|
|
195
|
+
>>> async with MegaplanClient(...) as client:
|
|
196
|
+
... await client.employees.list() # Caches employees
|
|
197
|
+
... client.clear_cache_type("Employee") # Clear only employees
|
|
198
|
+
"""
|
|
199
|
+
if self._cache:
|
|
200
|
+
self._cache.clear_type(content_type)
|
|
201
|
+
logger.debug(f"Entity cache cleared for type: {content_type}")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Constants for Megaplan SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ContentType:
|
|
5
|
+
"""Content type constants for Megaplan API entities."""
|
|
6
|
+
|
|
7
|
+
TASK = "Task"
|
|
8
|
+
PROJECT = "Project"
|
|
9
|
+
DEAL = "Deal"
|
|
10
|
+
EMPLOYEE = "Employee"
|
|
11
|
+
CONTRACTOR = "Contractor"
|
|
12
|
+
CONTRACTOR_COMPANY = "ContractorCompany"
|
|
13
|
+
CONTRACTOR_HUMAN = "ContractorHuman"
|
|
14
|
+
CONTRACTOR_CATEGORY = "ContractorCategory"
|
|
15
|
+
DEPARTMENT = "Department"
|
|
16
|
+
COMMENT = "Comment"
|