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
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
"""Base resource class for Megaplan API resources."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from megaplan_sdk.constants import ContentType
|
|
8
|
+
from megaplan_sdk.http_client import HTTPClient
|
|
9
|
+
from megaplan_sdk.logging_config import logger
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from megaplan_sdk.cache import EntityCache
|
|
13
|
+
from megaplan_sdk.models.comment import Comment
|
|
14
|
+
from megaplan_sdk.models.contractor import Contractor
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseResource:
|
|
20
|
+
"""Base class for all API resources.
|
|
21
|
+
|
|
22
|
+
Provides common functionality for making API requests.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
http_client: HTTPClient,
|
|
28
|
+
cache: "EntityCache | None" = None,
|
|
29
|
+
default_comments_limit: int | None = None,
|
|
30
|
+
default_history_limit: int | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize base resource.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
http_client: HTTP client for making requests.
|
|
36
|
+
cache: Optional entity cache for caching related entities.
|
|
37
|
+
default_comments_limit: Default limit for comments in get_full_details().
|
|
38
|
+
None = use API default (no explicit limit).
|
|
39
|
+
default_history_limit: Default limit for history in get_full_details().
|
|
40
|
+
None = use API default (no explicit limit).
|
|
41
|
+
"""
|
|
42
|
+
self._http = http_client
|
|
43
|
+
self._cache = cache
|
|
44
|
+
self._default_comments_limit = default_comments_limit
|
|
45
|
+
self._default_history_limit = default_history_limit
|
|
46
|
+
|
|
47
|
+
def _build_path(self, *parts: str) -> str:
|
|
48
|
+
"""Build API path from parts.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
*parts: Path parts.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Combined path.
|
|
55
|
+
"""
|
|
56
|
+
return "/" + "/".join(str(part).strip("/") for part in parts if part)
|
|
57
|
+
|
|
58
|
+
def _prepare_params(self, **kwargs: Any) -> dict[str, Any] | None:
|
|
59
|
+
"""Prepare query parameters, removing None values.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
**kwargs: Parameters to include.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Dictionary with non-None parameters.
|
|
66
|
+
"""
|
|
67
|
+
params = {k: v for k, v in kwargs.items() if v is not None}
|
|
68
|
+
return params if params else None
|
|
69
|
+
|
|
70
|
+
def _build_list_params(
|
|
71
|
+
self,
|
|
72
|
+
filter: Any | None = None,
|
|
73
|
+
limit: int | None = None,
|
|
74
|
+
page_after: dict[str, Any] | None = None,
|
|
75
|
+
page_before: dict[str, Any] | None = None,
|
|
76
|
+
page_with: dict[str, Any] | None = None,
|
|
77
|
+
fields: Any | None = None,
|
|
78
|
+
sort_by: list[dict[str, str]] | None = None,
|
|
79
|
+
only_requested_fields: bool | None = None,
|
|
80
|
+
**extra_params: Any,
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
"""Build standard list parameters for pagination and filtering.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
filter: Filter ID or configuration.
|
|
86
|
+
limit: Number of items per page.
|
|
87
|
+
page_after: Load page starting from this entity.
|
|
88
|
+
page_before: Load page strictly before this entity.
|
|
89
|
+
page_with: Load page containing this entity.
|
|
90
|
+
fields: Additional fields to include.
|
|
91
|
+
sort_by: Sort fields.
|
|
92
|
+
only_requested_fields: Return only requested fields.
|
|
93
|
+
**extra_params: Additional parameters (e.g., statuses, status, q).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Dictionary with non-None parameters.
|
|
97
|
+
"""
|
|
98
|
+
params: dict[str, Any] = {}
|
|
99
|
+
|
|
100
|
+
if filter is not None:
|
|
101
|
+
params["filter"] = filter
|
|
102
|
+
if limit is not None:
|
|
103
|
+
params["limit"] = limit
|
|
104
|
+
if page_after:
|
|
105
|
+
params["pageAfter"] = page_after
|
|
106
|
+
if page_before:
|
|
107
|
+
params["pageBefore"] = page_before
|
|
108
|
+
if page_with:
|
|
109
|
+
params["pageWith"] = page_with
|
|
110
|
+
if fields is not None:
|
|
111
|
+
params["fields"] = fields
|
|
112
|
+
if sort_by:
|
|
113
|
+
params["sortBy"] = sort_by
|
|
114
|
+
if only_requested_fields is not None:
|
|
115
|
+
params["onlyRequestedFields"] = only_requested_fields
|
|
116
|
+
|
|
117
|
+
# Add extra params (like statuses, status, q, baseOn, etc.)
|
|
118
|
+
params.update(extra_params)
|
|
119
|
+
|
|
120
|
+
return params if params else {}
|
|
121
|
+
|
|
122
|
+
async def _iterate_generic(
|
|
123
|
+
self,
|
|
124
|
+
content_type: str,
|
|
125
|
+
list_method: Any, # Method bound to instance, not a standalone Callable
|
|
126
|
+
limit: int = 100,
|
|
127
|
+
**kwargs: Any,
|
|
128
|
+
) -> AsyncIterator[T]:
|
|
129
|
+
"""Generic iterator for paginating through resources.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
content_type: Entity type for pageAfter (e.g. "Task", "Deal").
|
|
133
|
+
list_method: The list() method to call for pagination.
|
|
134
|
+
limit: Items per page.
|
|
135
|
+
**kwargs: Additional parameters to pass to list_method.
|
|
136
|
+
|
|
137
|
+
Yields:
|
|
138
|
+
Individual items from the paginated results.
|
|
139
|
+
"""
|
|
140
|
+
page_after = None
|
|
141
|
+
|
|
142
|
+
while True:
|
|
143
|
+
items: list[T] = await list_method(limit=limit, page_after=page_after, **kwargs)
|
|
144
|
+
if not items:
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
for item in items:
|
|
148
|
+
yield item
|
|
149
|
+
|
|
150
|
+
if len(items) < limit:
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
last_item = items[-1]
|
|
154
|
+
# TypeVar T doesn't guarantee .id attribute, but all our entities have it
|
|
155
|
+
item_id: int = getattr(last_item, "id", 0)
|
|
156
|
+
page_after = {"contentType": content_type, "id": item_id}
|
|
157
|
+
|
|
158
|
+
async def _get_entity_comments(
|
|
159
|
+
self,
|
|
160
|
+
entity_type: str,
|
|
161
|
+
entity_id: int,
|
|
162
|
+
limit: int | None = None,
|
|
163
|
+
page_after: dict[str, Any] | None = None,
|
|
164
|
+
page_before: dict[str, Any] | None = None,
|
|
165
|
+
page_with: dict[str, Any] | None = None,
|
|
166
|
+
) -> list["Comment"]:
|
|
167
|
+
"""Generic method to get comments for any entity.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
entity_type: API path segment (e.g. "todo" for tasks, "project", "deal", "contractor").
|
|
171
|
+
entity_id: Entity identifier.
|
|
172
|
+
limit: Number of items per page.
|
|
173
|
+
page_after: Load page starting from this entity.
|
|
174
|
+
page_before: Load page strictly before this entity.
|
|
175
|
+
page_with: Load page containing this entity.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
List of comments.
|
|
179
|
+
"""
|
|
180
|
+
from megaplan_sdk.models.comment import Comment
|
|
181
|
+
|
|
182
|
+
path = self._build_path("api", "v3", entity_type, str(entity_id), "comments")
|
|
183
|
+
|
|
184
|
+
params = self._build_list_params(
|
|
185
|
+
limit=limit,
|
|
186
|
+
page_after=page_after,
|
|
187
|
+
page_before=page_before,
|
|
188
|
+
page_with=page_with,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
response = await self._http.get(path, params=params if params else None)
|
|
192
|
+
data = self._parse_list_response(response)
|
|
193
|
+
|
|
194
|
+
return [Comment(**item) if isinstance(item, dict) else item for item in data]
|
|
195
|
+
|
|
196
|
+
async def _create_entity_comment(
|
|
197
|
+
self,
|
|
198
|
+
entity_type: str,
|
|
199
|
+
entity_id: int,
|
|
200
|
+
text: str,
|
|
201
|
+
attaches: "list[dict[str, Any]] | None" = None,
|
|
202
|
+
**extra_fields: Any,
|
|
203
|
+
) -> "Comment":
|
|
204
|
+
"""Generic method to create comment for any entity.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
entity_type: API path segment.
|
|
208
|
+
entity_id: Entity identifier.
|
|
209
|
+
text: Comment text.
|
|
210
|
+
attaches: File attachments.
|
|
211
|
+
**extra_fields: Additional fields (e.g. work for tasks).
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Created comment.
|
|
215
|
+
"""
|
|
216
|
+
from megaplan_sdk.models.comment import Comment
|
|
217
|
+
|
|
218
|
+
path = self._build_path("api", "v3", entity_type, str(entity_id), "comments")
|
|
219
|
+
|
|
220
|
+
comment_data: dict[str, Any] = {"content": text}
|
|
221
|
+
if attaches:
|
|
222
|
+
comment_data["attaches"] = attaches
|
|
223
|
+
comment_data.update(extra_fields)
|
|
224
|
+
|
|
225
|
+
response = await self._http.post(path, json_data=comment_data)
|
|
226
|
+
return Comment(**response["data"])
|
|
227
|
+
|
|
228
|
+
async def _get_list(
|
|
229
|
+
self,
|
|
230
|
+
path: str,
|
|
231
|
+
model_class: type[T],
|
|
232
|
+
params: dict[str, Any] | None = None,
|
|
233
|
+
) -> list[T]:
|
|
234
|
+
"""Generic method to fetch and parse list response.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
path: API endpoint path.
|
|
238
|
+
model_class: Pydantic model class for items.
|
|
239
|
+
params: Query parameters.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
List of model instances.
|
|
243
|
+
"""
|
|
244
|
+
response = await self._http.get(path, params=params)
|
|
245
|
+
data = self._parse_list_response(response)
|
|
246
|
+
|
|
247
|
+
return [model_class(**item) if isinstance(item, dict) else item for item in data]
|
|
248
|
+
|
|
249
|
+
async def _create_entity(
|
|
250
|
+
self,
|
|
251
|
+
entity_type: str,
|
|
252
|
+
data: dict[str, Any],
|
|
253
|
+
model_class: type[T],
|
|
254
|
+
) -> T:
|
|
255
|
+
"""Generic create method.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
entity_type: API resource type (e.g. "task", "project").
|
|
259
|
+
data: Entity data.
|
|
260
|
+
model_class: Pydantic model class.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Created entity instance.
|
|
264
|
+
"""
|
|
265
|
+
path = self._build_path("api", "v3", entity_type)
|
|
266
|
+
response = await self._http.post(path, json_data=data)
|
|
267
|
+
return model_class(**response["data"])
|
|
268
|
+
|
|
269
|
+
async def _get_entity(
|
|
270
|
+
self,
|
|
271
|
+
entity_type: str,
|
|
272
|
+
entity_id: int,
|
|
273
|
+
model_class: type[T],
|
|
274
|
+
) -> T:
|
|
275
|
+
"""Generic get method.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
entity_type: API resource type.
|
|
279
|
+
entity_id: Entity identifier.
|
|
280
|
+
model_class: Pydantic model class.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Entity instance.
|
|
284
|
+
"""
|
|
285
|
+
path = self._build_path("api", "v3", entity_type, str(entity_id))
|
|
286
|
+
response = await self._http.get(path)
|
|
287
|
+
return model_class(**response["data"])
|
|
288
|
+
|
|
289
|
+
async def _update_entity(
|
|
290
|
+
self,
|
|
291
|
+
entity_type: str,
|
|
292
|
+
entity_id: int,
|
|
293
|
+
data: dict[str, Any],
|
|
294
|
+
model_class: type[T],
|
|
295
|
+
) -> T:
|
|
296
|
+
"""Generic update method.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
entity_type: API resource type.
|
|
300
|
+
entity_id: Entity identifier.
|
|
301
|
+
data: Updated entity data.
|
|
302
|
+
model_class: Pydantic model class.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Updated entity instance.
|
|
306
|
+
"""
|
|
307
|
+
path = self._build_path("api", "v3", entity_type, str(entity_id))
|
|
308
|
+
response = await self._http.post(path, json_data=data)
|
|
309
|
+
return model_class(**response["data"])
|
|
310
|
+
|
|
311
|
+
async def _delete_entity(
|
|
312
|
+
self,
|
|
313
|
+
entity_type: str,
|
|
314
|
+
entity_id: int,
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Generic delete method.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
entity_type: API resource type.
|
|
320
|
+
entity_id: Entity identifier.
|
|
321
|
+
"""
|
|
322
|
+
path = self._build_path("api", "v3", entity_type, str(entity_id))
|
|
323
|
+
await self._http.delete(path)
|
|
324
|
+
|
|
325
|
+
async def _get_entity_cached(
|
|
326
|
+
self,
|
|
327
|
+
entity_type: str,
|
|
328
|
+
entity_id: int,
|
|
329
|
+
model_class: type[T],
|
|
330
|
+
use_cache: bool = True,
|
|
331
|
+
) -> T:
|
|
332
|
+
"""Get entity with optional caching.
|
|
333
|
+
|
|
334
|
+
Fetches entity from cache if available and not expired,
|
|
335
|
+
otherwise fetches from API and caches result.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
entity_type: API resource type (e.g., "employee", "task").
|
|
339
|
+
entity_id: Entity identifier.
|
|
340
|
+
model_class: Pydantic model class for parsing.
|
|
341
|
+
use_cache: Whether to use cache (default: True).
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Entity instance.
|
|
345
|
+
|
|
346
|
+
Examples:
|
|
347
|
+
>>> employee = await resource._get_entity_cached(
|
|
348
|
+
... "employee", 123, Employee
|
|
349
|
+
... )
|
|
350
|
+
"""
|
|
351
|
+
# Determine contentType from entity_type
|
|
352
|
+
content_type = self._entity_type_to_content_type(entity_type)
|
|
353
|
+
|
|
354
|
+
# Try cache first
|
|
355
|
+
if use_cache and self._cache:
|
|
356
|
+
cached = self._cache.get(content_type, entity_id)
|
|
357
|
+
if cached is not None:
|
|
358
|
+
# Cache stores dict, convert to model
|
|
359
|
+
return model_class(**cached) if isinstance(cached, dict) else cached
|
|
360
|
+
|
|
361
|
+
# Fetch from API
|
|
362
|
+
entity = await self._get_entity(entity_type, entity_id, model_class)
|
|
363
|
+
|
|
364
|
+
# Store in cache
|
|
365
|
+
if use_cache and self._cache:
|
|
366
|
+
# Store as dict for consistency
|
|
367
|
+
# TypeVar T doesn't guarantee .model_dump, but all Pydantic models have it
|
|
368
|
+
entity_dict = entity.model_dump(by_alias=True) # type: ignore[attr-defined]
|
|
369
|
+
self._cache.set(content_type, entity_id, entity_dict)
|
|
370
|
+
|
|
371
|
+
return entity
|
|
372
|
+
|
|
373
|
+
async def _load_related_entities(
|
|
374
|
+
self,
|
|
375
|
+
entities: list[Any],
|
|
376
|
+
entity_type: str,
|
|
377
|
+
model_class: type[T],
|
|
378
|
+
) -> dict[int, T]:
|
|
379
|
+
"""Batch load related entities with caching.
|
|
380
|
+
|
|
381
|
+
Collects unique entity IDs, checks cache for each,
|
|
382
|
+
then fetches missing ones in parallel.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
entities: List of BaseEntity references to load (can contain None).
|
|
386
|
+
entity_type: API resource type (e.g., "employee", "contractor").
|
|
387
|
+
model_class: Pydantic model class.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Dict mapping entity ID to loaded entity.
|
|
391
|
+
|
|
392
|
+
Examples:
|
|
393
|
+
>>> # Load all unique responsible employees from tasks
|
|
394
|
+
>>> responsible_refs = [task.responsible for task in tasks]
|
|
395
|
+
>>> employees = await resource._load_related_entities(
|
|
396
|
+
... responsible_refs, "employee", Employee
|
|
397
|
+
... )
|
|
398
|
+
>>> # employees = {123: Employee(...), 456: Employee(...)}
|
|
399
|
+
"""
|
|
400
|
+
# Collect unique IDs (filter out None and extract id attribute)
|
|
401
|
+
unique_ids: set[int] = set()
|
|
402
|
+
for entity in entities:
|
|
403
|
+
if entity is not None and hasattr(entity, "id"):
|
|
404
|
+
unique_ids.add(entity.id)
|
|
405
|
+
|
|
406
|
+
if not unique_ids:
|
|
407
|
+
return {}
|
|
408
|
+
|
|
409
|
+
content_type = self._entity_type_to_content_type(entity_type)
|
|
410
|
+
result: dict[int, T] = {}
|
|
411
|
+
ids_to_fetch: set[int] = set()
|
|
412
|
+
|
|
413
|
+
# Check cache for each ID
|
|
414
|
+
if self._cache:
|
|
415
|
+
for entity_id in unique_ids:
|
|
416
|
+
cached = self._cache.get(content_type, entity_id)
|
|
417
|
+
if cached is not None:
|
|
418
|
+
result[entity_id] = (
|
|
419
|
+
model_class(**cached) if isinstance(cached, dict) else cached
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
ids_to_fetch.add(entity_id)
|
|
423
|
+
else:
|
|
424
|
+
ids_to_fetch = unique_ids
|
|
425
|
+
|
|
426
|
+
# Fetch missing entities in parallel
|
|
427
|
+
if ids_to_fetch:
|
|
428
|
+
fetch_tasks = [
|
|
429
|
+
self._get_entity_cached(entity_type, entity_id, model_class, use_cache=True)
|
|
430
|
+
for entity_id in ids_to_fetch
|
|
431
|
+
]
|
|
432
|
+
fetched = await asyncio.gather(*fetch_tasks, return_exceptions=True)
|
|
433
|
+
|
|
434
|
+
for entity_id, entity in zip(ids_to_fetch, fetched, strict=True):
|
|
435
|
+
if not isinstance(entity, Exception):
|
|
436
|
+
result[entity_id] = entity # type: ignore[assignment]
|
|
437
|
+
# Ignore exceptions during batch loading
|
|
438
|
+
|
|
439
|
+
return result
|
|
440
|
+
|
|
441
|
+
@staticmethod
|
|
442
|
+
def _entity_type_to_content_type(entity_type: str) -> str:
|
|
443
|
+
"""Convert API entity type to contentType.
|
|
444
|
+
|
|
445
|
+
Uses explicit mapping to avoid issues with capitalize() on CamelCase names.
|
|
446
|
+
For example, "contractorCompany" would become "Contractorcompany" with capitalize(),
|
|
447
|
+
but API expects "ContractorCompany".
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
entity_type: API resource type (e.g., "employee", "task", "todo", "contractorCompany").
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
ContentType string (e.g., "Employee", "Task", "ContractorCompany").
|
|
454
|
+
|
|
455
|
+
Examples:
|
|
456
|
+
>>> BaseResource._entity_type_to_content_type("employee")
|
|
457
|
+
'Employee'
|
|
458
|
+
>>> BaseResource._entity_type_to_content_type("task")
|
|
459
|
+
'Task'
|
|
460
|
+
>>> BaseResource._entity_type_to_content_type("todo")
|
|
461
|
+
'Task'
|
|
462
|
+
>>> BaseResource._entity_type_to_content_type("contractorCompany")
|
|
463
|
+
'ContractorCompany'
|
|
464
|
+
"""
|
|
465
|
+
# Map API path segments to ContentTypes
|
|
466
|
+
# This avoids issues with capitalize() on CamelCase names
|
|
467
|
+
mapping = {
|
|
468
|
+
"todo": ContentType.TASK,
|
|
469
|
+
"task": ContentType.TASK,
|
|
470
|
+
"project": ContentType.PROJECT,
|
|
471
|
+
"deal": ContentType.DEAL,
|
|
472
|
+
"employee": ContentType.EMPLOYEE,
|
|
473
|
+
"contractor": ContentType.CONTRACTOR,
|
|
474
|
+
"department": ContentType.DEPARTMENT,
|
|
475
|
+
"contractorCompany": ContentType.CONTRACTOR_COMPANY,
|
|
476
|
+
"contractorHuman": ContentType.CONTRACTOR_HUMAN,
|
|
477
|
+
"comment": ContentType.COMMENT,
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
result = mapping.get(entity_type)
|
|
481
|
+
if result:
|
|
482
|
+
return result
|
|
483
|
+
|
|
484
|
+
# Fallback: capitalize first letter for unknown types
|
|
485
|
+
# Log warning in production if this path is hit frequently
|
|
486
|
+
return entity_type.capitalize()
|
|
487
|
+
|
|
488
|
+
@staticmethod
|
|
489
|
+
def _parse_contractor_response(data: dict[str, Any]) -> "Contractor":
|
|
490
|
+
"""Parse contractor response and return appropriate type.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
data: Contractor data dictionary.
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
Contractor, ContractorCompany, or ContractorHuman instance.
|
|
497
|
+
"""
|
|
498
|
+
from megaplan_sdk.models.contractor import Contractor, ContractorCompany, ContractorHuman
|
|
499
|
+
|
|
500
|
+
content_type = data.get("contentType", ContentType.CONTRACTOR)
|
|
501
|
+
if content_type == ContentType.CONTRACTOR_COMPANY:
|
|
502
|
+
return ContractorCompany(**data)
|
|
503
|
+
elif content_type == ContentType.CONTRACTOR_HUMAN:
|
|
504
|
+
return ContractorHuman(**data)
|
|
505
|
+
return Contractor(**data)
|
|
506
|
+
|
|
507
|
+
async def _get_entity_related_list(
|
|
508
|
+
self,
|
|
509
|
+
entity_type: str,
|
|
510
|
+
entity_id: int,
|
|
511
|
+
related_type: str,
|
|
512
|
+
limit: int | None = None,
|
|
513
|
+
page_after: dict[str, Any] | None = None,
|
|
514
|
+
page_before: dict[str, Any] | None = None,
|
|
515
|
+
page_with: dict[str, Any] | None = None,
|
|
516
|
+
) -> list[Any]:
|
|
517
|
+
"""Generic method to get related list (auditors, executors, milestones).
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
entity_type: API resource type (e.g., "task", "project").
|
|
521
|
+
entity_id: Entity identifier.
|
|
522
|
+
related_type: Related resource type (e.g., "auditors", "executors", "milestones").
|
|
523
|
+
limit: Number of items per page.
|
|
524
|
+
page_after: Load page starting from this entity.
|
|
525
|
+
page_before: Load page strictly before this entity.
|
|
526
|
+
page_with: Load page containing this entity.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
List of related entities.
|
|
530
|
+
"""
|
|
531
|
+
path = self._build_path("api", "v3", entity_type, str(entity_id), related_type)
|
|
532
|
+
|
|
533
|
+
params = self._build_list_params(
|
|
534
|
+
limit=limit,
|
|
535
|
+
page_after=page_after,
|
|
536
|
+
page_before=page_before,
|
|
537
|
+
page_with=page_with,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
response = await self._http.get(path, params=params if params else None)
|
|
541
|
+
return self._parse_list_response(response)
|
|
542
|
+
|
|
543
|
+
async def _add_entity_related(
|
|
544
|
+
self,
|
|
545
|
+
entity_type: str,
|
|
546
|
+
entity_id: int,
|
|
547
|
+
related_type: str,
|
|
548
|
+
related_id: int,
|
|
549
|
+
related_content_type: str = ContentType.EMPLOYEE,
|
|
550
|
+
data_override: dict[str, Any] | None = None,
|
|
551
|
+
) -> Any:
|
|
552
|
+
"""Generic method to add related entity (auditor, executor, milestone).
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
entity_type: API resource type (e.g., "task", "project").
|
|
556
|
+
entity_id: Entity identifier.
|
|
557
|
+
related_type: Related resource type (e.g., "auditors", "executors", "milestones").
|
|
558
|
+
related_id: Related entity ID.
|
|
559
|
+
related_content_type: Content type of related entity (usually "Employee").
|
|
560
|
+
data_override: Optional data override (for milestones with custom data).
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Added related entity.
|
|
564
|
+
"""
|
|
565
|
+
path = self._build_path("api", "v3", entity_type, str(entity_id), related_type)
|
|
566
|
+
|
|
567
|
+
if data_override:
|
|
568
|
+
related_data = data_override
|
|
569
|
+
else:
|
|
570
|
+
related_data = {"id": related_id, "contentType": related_content_type}
|
|
571
|
+
|
|
572
|
+
response = await self._http.post(path, json_data=related_data)
|
|
573
|
+
return self._parse_single_response(response)
|
|
574
|
+
|
|
575
|
+
async def _remove_entity_related(
|
|
576
|
+
self,
|
|
577
|
+
entity_type: str,
|
|
578
|
+
entity_id: int,
|
|
579
|
+
related_type: str,
|
|
580
|
+
related_id: int,
|
|
581
|
+
related_content_type: str = ContentType.EMPLOYEE,
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Generic method to remove related entity (auditor, executor, milestone).
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
entity_type: API resource type (e.g., "task", "project").
|
|
587
|
+
entity_id: Entity identifier.
|
|
588
|
+
related_type: Related resource type (e.g., "auditors", "executors", "milestones").
|
|
589
|
+
related_id: Related entity ID.
|
|
590
|
+
related_content_type: Content type of related entity (usually "Employee").
|
|
591
|
+
"""
|
|
592
|
+
path = self._build_path(
|
|
593
|
+
"api",
|
|
594
|
+
"v3",
|
|
595
|
+
entity_type,
|
|
596
|
+
str(entity_id),
|
|
597
|
+
related_type,
|
|
598
|
+
related_content_type,
|
|
599
|
+
str(related_id),
|
|
600
|
+
)
|
|
601
|
+
await self._http.delete(path)
|
|
602
|
+
|
|
603
|
+
async def _get_entity_history(
|
|
604
|
+
self,
|
|
605
|
+
entity_type: str,
|
|
606
|
+
entity_id: int,
|
|
607
|
+
limit: int | None = None,
|
|
608
|
+
page_after: dict[str, Any] | None = None,
|
|
609
|
+
page_before: dict[str, Any] | None = None,
|
|
610
|
+
page_with: dict[str, Any] | None = None,
|
|
611
|
+
) -> list[dict[str, Any]]:
|
|
612
|
+
"""Generic method to get history log for any entity.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
entity_type: API resource type (e.g., "task", "project", "deal").
|
|
616
|
+
entity_id: Entity identifier.
|
|
617
|
+
limit: Number of items per page.
|
|
618
|
+
page_after: Load page starting from this entity.
|
|
619
|
+
page_before: Load page strictly before this entity.
|
|
620
|
+
page_with: Load page containing this entity.
|
|
621
|
+
|
|
622
|
+
Returns:
|
|
623
|
+
List of history entries.
|
|
624
|
+
"""
|
|
625
|
+
path = self._build_path("api", "v3", entity_type, str(entity_id), "history")
|
|
626
|
+
|
|
627
|
+
params = self._build_list_params(
|
|
628
|
+
limit=limit,
|
|
629
|
+
page_after=page_after,
|
|
630
|
+
page_before=page_before,
|
|
631
|
+
page_with=page_with,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
response = await self._http.get(path, params=params if params else None)
|
|
635
|
+
return self._parse_list_response(response)
|
|
636
|
+
|
|
637
|
+
async def _search_entity_history(
|
|
638
|
+
self,
|
|
639
|
+
entity_type: str,
|
|
640
|
+
entity_id: int,
|
|
641
|
+
query: str,
|
|
642
|
+
limit: int | None = None,
|
|
643
|
+
page_after: dict[str, Any] | None = None,
|
|
644
|
+
page_before: dict[str, Any] | None = None,
|
|
645
|
+
page_with: dict[str, Any] | None = None,
|
|
646
|
+
) -> list[dict[str, Any]]:
|
|
647
|
+
"""Generic method to search in entity history log.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
entity_type: API resource type (e.g., "task", "project", "deal").
|
|
651
|
+
entity_id: Entity identifier.
|
|
652
|
+
query: Search query.
|
|
653
|
+
limit: Number of items per page.
|
|
654
|
+
page_after: Load page starting from this entity.
|
|
655
|
+
page_before: Load page strictly before this entity.
|
|
656
|
+
page_with: Load page containing this entity.
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
List of matching history entries.
|
|
660
|
+
"""
|
|
661
|
+
path = self._build_path("api", "v3", entity_type, str(entity_id), "history", "search")
|
|
662
|
+
|
|
663
|
+
params = self._build_list_params(
|
|
664
|
+
limit=limit,
|
|
665
|
+
page_after=page_after,
|
|
666
|
+
page_before=page_before,
|
|
667
|
+
page_with=page_with,
|
|
668
|
+
)
|
|
669
|
+
params_dict: dict[str, Any] = params if params is not None else {}
|
|
670
|
+
params_dict["q"] = query
|
|
671
|
+
|
|
672
|
+
response = await self._http.get(path, params=params_dict)
|
|
673
|
+
return self._parse_list_response(response)
|
|
674
|
+
|
|
675
|
+
async def _fetch_details_parallel(
|
|
676
|
+
self,
|
|
677
|
+
fetch_map: dict[str, Any],
|
|
678
|
+
) -> dict[str, Any]:
|
|
679
|
+
"""Execute fetch tasks in parallel safely.
|
|
680
|
+
|
|
681
|
+
Uses return_exceptions=True to prevent one failed fetch
|
|
682
|
+
from breaking the entire request. Errors are logged but
|
|
683
|
+
don't stop execution.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
fetch_map: Dictionary of task_name -> coroutine.
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
Dictionary of task_name -> result (None on error).
|
|
690
|
+
"""
|
|
691
|
+
if not fetch_map:
|
|
692
|
+
return {}
|
|
693
|
+
|
|
694
|
+
results = await asyncio.gather(*fetch_map.values(), return_exceptions=True)
|
|
695
|
+
|
|
696
|
+
final_data: dict[str, Any] = {}
|
|
697
|
+
for key, result in zip(fetch_map.keys(), results, strict=True):
|
|
698
|
+
if isinstance(result, Exception):
|
|
699
|
+
logger.warning(f"Failed to fetch {key}: {result}")
|
|
700
|
+
final_data[key] = None
|
|
701
|
+
else:
|
|
702
|
+
final_data[key] = result
|
|
703
|
+
|
|
704
|
+
return final_data
|
|
705
|
+
|
|
706
|
+
def _parse_list_response(self, response: dict[str, Any]) -> list[Any]:
|
|
707
|
+
"""Parse list response from API.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
response: API response dictionary.
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
List of items from response data.
|
|
714
|
+
"""
|
|
715
|
+
data = response.get("data", [])
|
|
716
|
+
return data if isinstance(data, list) else []
|
|
717
|
+
|
|
718
|
+
def _parse_single_response(self, response: dict[str, Any]) -> dict[str, Any]:
|
|
719
|
+
"""Parse single entity response from API.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
response: API response dictionary.
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
Entity data dictionary.
|
|
726
|
+
"""
|
|
727
|
+
data = response.get("data", {})
|
|
728
|
+
return data if isinstance(data, dict) else {}
|
|
729
|
+
|
|
730
|
+
async def _expand_list_entities(
|
|
731
|
+
self,
|
|
732
|
+
entities: list[T],
|
|
733
|
+
expand: list[str] | None,
|
|
734
|
+
expand_config: dict[str, tuple[str, type, str]],
|
|
735
|
+
) -> dict[str, dict[int, Any]]:
|
|
736
|
+
"""Expand related entities for list of entities.
|
|
737
|
+
|
|
738
|
+
Unified method to handle expand logic in list() methods.
|
|
739
|
+
Returns empty dict if expand is None or empty, or if entities list is empty.
|
|
740
|
+
|
|
741
|
+
Args:
|
|
742
|
+
entities: List of entities to expand.
|
|
743
|
+
expand: List of field names to expand (None or empty means no expansion).
|
|
744
|
+
expand_config: Configuration mapping field_name -> (entity_type, model_class, content_type).
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Dictionary mapping field_name -> {entity_id -> loaded_entity}.
|
|
748
|
+
"""
|
|
749
|
+
if not expand or not entities:
|
|
750
|
+
return {}
|
|
751
|
+
|
|
752
|
+
return await self._expand_entities(entities, expand, expand_config)
|
|
753
|
+
|
|
754
|
+
async def _expand_entities(
|
|
755
|
+
self,
|
|
756
|
+
entities: list[T],
|
|
757
|
+
expand: list[str],
|
|
758
|
+
expand_config: dict[str, tuple[str, type, str]],
|
|
759
|
+
) -> dict[str, dict[int, Any]]:
|
|
760
|
+
"""Generic method to expand related entities.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
entities: List of entities to expand.
|
|
764
|
+
expand: List of field names to expand.
|
|
765
|
+
expand_config: Configuration mapping field_name -> (entity_type, model_class, content_type).
|
|
766
|
+
|
|
767
|
+
Returns:
|
|
768
|
+
Dictionary mapping field_name -> {entity_id -> loaded_entity}.
|
|
769
|
+
"""
|
|
770
|
+
if not expand or not entities:
|
|
771
|
+
return {}
|
|
772
|
+
|
|
773
|
+
result: dict[str, dict[int, Any]] = {}
|
|
774
|
+
|
|
775
|
+
for field_name in expand:
|
|
776
|
+
if field_name not in expand_config:
|
|
777
|
+
continue
|
|
778
|
+
|
|
779
|
+
entity_type, model_class, _ = expand_config[field_name]
|
|
780
|
+
|
|
781
|
+
# Collect references for this field
|
|
782
|
+
refs = []
|
|
783
|
+
for entity in entities:
|
|
784
|
+
field_value = getattr(entity, field_name, None)
|
|
785
|
+
if field_value is not None and hasattr(field_value, "id"):
|
|
786
|
+
refs.append(field_value)
|
|
787
|
+
|
|
788
|
+
if refs:
|
|
789
|
+
loaded: dict[int, T] = await self._load_related_entities(
|
|
790
|
+
refs, entity_type, model_class
|
|
791
|
+
)
|
|
792
|
+
result[field_name] = loaded
|
|
793
|
+
|
|
794
|
+
return result
|