tap-asana 2.3.0__tar.gz → 2.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tap-asana-2.3.0/tap_asana.egg-info → tap_asana-2.4.0}/PKG-INFO +12 -6
- {tap-asana-2.3.0 → tap_asana-2.4.0}/setup.py +8 -9
- tap_asana-2.4.0/tap_asana/asana.py +67 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/portfolios.json +53 -2
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/projects.json +1 -1
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/stories.json +6 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/teams.json +24 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/base.py +101 -32
- tap_asana-2.4.0/tap_asana/streams/portfolios.py +72 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/projects.py +29 -5
- tap_asana-2.4.0/tap_asana/streams/sections.py +47 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/stories.py +29 -19
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/subtasks.py +28 -18
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/tags.py +19 -5
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/tasks.py +28 -18
- tap_asana-2.4.0/tap_asana/streams/teams.py +54 -0
- tap_asana-2.4.0/tap_asana/streams/users.py +40 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/workspaces.py +13 -1
- {tap-asana-2.3.0 → tap_asana-2.4.0/tap_asana.egg-info}/PKG-INFO +12 -6
- tap_asana-2.4.0/tap_asana.egg-info/requires.txt +9 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_all_fields.py +21 -4
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_automatic_fields.py +2 -1
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_bookmarks.py +2 -1
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_start_date.py +2 -1
- tap-asana-2.3.0/tap_asana/asana.py +0 -57
- tap-asana-2.3.0/tap_asana/streams/portfolios.py +0 -53
- tap-asana-2.3.0/tap_asana/streams/sections.py +0 -40
- tap-asana-2.3.0/tap_asana/streams/teams.py +0 -39
- tap-asana-2.3.0/tap_asana/streams/users.py +0 -28
- tap-asana-2.3.0/tap_asana.egg-info/requires.txt +0 -10
- {tap-asana-2.3.0 → tap_asana-2.4.0}/LICENSE +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/MANIFEST.in +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/README.md +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/setup.cfg +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/__init__.py +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/context.py +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/sections.json +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/subtasks.json +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/tags.json +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/tasks.json +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/users.json +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/workspaces.json +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/__init__.py +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana.egg-info/SOURCES.txt +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana.egg-info/dependency_links.txt +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana.egg-info/entry_points.txt +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana.egg-info/top_level.txt +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_discovery.py +0 -0
- {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_interrupted_sync.py +0 -0
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: tap-asana
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Singer.io tap for extracting Asana data
|
|
5
5
|
Home-page: http://github.com/singer-io/tap-asana
|
|
6
6
|
Author: Stitch
|
|
7
7
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
8
8
|
License-File: LICENSE
|
|
9
|
-
Requires-Dist: asana==
|
|
10
|
-
Requires-Dist:
|
|
9
|
+
Requires-Dist: asana==5.1.0
|
|
10
|
+
Requires-Dist: requests==2.32.4
|
|
11
|
+
Requires-Dist: singer-python==6.1.1
|
|
11
12
|
Provides-Extra: test
|
|
12
13
|
Requires-Dist: pylint; extra == "test"
|
|
13
|
-
Requires-Dist: requests==2.20.0; extra == "test"
|
|
14
|
-
Requires-Dist: nose; extra == "test"
|
|
15
14
|
Provides-Extra: dev
|
|
16
15
|
Requires-Dist: ipdb; extra == "dev"
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: home-page
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
Dynamic: provides-extra
|
|
21
|
+
Dynamic: requires-dist
|
|
22
|
+
Dynamic: summary
|
|
@@ -3,24 +3,23 @@ from setuptools import setup
|
|
|
3
3
|
|
|
4
4
|
setup(
|
|
5
5
|
name="tap-asana",
|
|
6
|
-
version="2.
|
|
6
|
+
version="2.4.0",
|
|
7
7
|
description="Singer.io tap for extracting Asana data",
|
|
8
8
|
author="Stitch",
|
|
9
9
|
url="http://github.com/singer-io/tap-asana",
|
|
10
10
|
classifiers=["Programming Language :: Python :: 3 :: Only"],
|
|
11
11
|
py_modules=["tap_asana"],
|
|
12
12
|
install_requires=[
|
|
13
|
-
"asana==
|
|
14
|
-
|
|
13
|
+
"asana==5.1.0",
|
|
14
|
+
"requests==2.32.4",
|
|
15
|
+
"singer-python==6.1.1"
|
|
15
16
|
],
|
|
16
17
|
extras_require={
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'requests==2.20.0',
|
|
20
|
-
'nose'
|
|
18
|
+
"test": [
|
|
19
|
+
"pylint"
|
|
21
20
|
],
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
"dev": [
|
|
22
|
+
"ipdb"
|
|
24
23
|
]
|
|
25
24
|
},
|
|
26
25
|
entry_points="""
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import asana
|
|
2
|
+
import singer
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
LOGGER = singer.get_logger()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
""" Simple wrapper for Asana. """
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# pylint: disable=too-many-positional-arguments
|
|
12
|
+
class Asana():
|
|
13
|
+
"""Base class for tap-asana"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self, client_id, client_secret, redirect_uri, refresh_token, access_token=None
|
|
17
|
+
): # pylint: disable=too-many-arguments
|
|
18
|
+
self.client_id = client_id
|
|
19
|
+
self.client_secret = client_secret
|
|
20
|
+
self.redirect_uri = redirect_uri
|
|
21
|
+
self.refresh_token = refresh_token
|
|
22
|
+
self.access_token = access_token
|
|
23
|
+
self._client = self._access_token_auth()
|
|
24
|
+
|
|
25
|
+
def _access_token_auth(self):
|
|
26
|
+
"""Check for access token"""
|
|
27
|
+
if self.access_token is None:
|
|
28
|
+
self.access_token = self.refresh_access_token()
|
|
29
|
+
|
|
30
|
+
if self.access_token:
|
|
31
|
+
try:
|
|
32
|
+
configuration = asana.Configuration()
|
|
33
|
+
configuration.access_token = self.access_token
|
|
34
|
+
return asana.ApiClient(configuration)
|
|
35
|
+
except asana.rest.ApiException as e:
|
|
36
|
+
LOGGER.error("Error creating Asana client: %s", e)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def refresh_access_token(self):
|
|
40
|
+
"""Get the access token using the refresh token"""
|
|
41
|
+
url = "https://app.asana.com/-/oauth_token"
|
|
42
|
+
payload = {
|
|
43
|
+
"grant_type": "refresh_token",
|
|
44
|
+
"client_id": self.client_id,
|
|
45
|
+
"client_secret": self.client_secret,
|
|
46
|
+
"redirect_uri": self.redirect_uri,
|
|
47
|
+
"refresh_token": self.refresh_token,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
response = requests.post(url, data=payload, headers=headers, timeout=30)
|
|
54
|
+
|
|
55
|
+
if response.status_code == 200:
|
|
56
|
+
LOGGER.debug("Access token refreshed successfully.")
|
|
57
|
+
if "access_token" in response.json():
|
|
58
|
+
self.access_token = response.json()["access_token"]
|
|
59
|
+
return response.json()["access_token"]
|
|
60
|
+
return None
|
|
61
|
+
except requests.exceptions.RequestException as e:
|
|
62
|
+
LOGGER.error("Failed to refresh access token: %s", e)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def client(self):
|
|
67
|
+
return self._client
|
|
@@ -466,7 +466,7 @@
|
|
|
466
466
|
]
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
|
-
}
|
|
469
|
+
}
|
|
470
470
|
}
|
|
471
471
|
}
|
|
472
472
|
},
|
|
@@ -645,7 +645,7 @@
|
|
|
645
645
|
"string"
|
|
646
646
|
]
|
|
647
647
|
}
|
|
648
|
-
}
|
|
648
|
+
}
|
|
649
649
|
},
|
|
650
650
|
"custom_fields": {
|
|
651
651
|
"type": [
|
|
@@ -872,6 +872,57 @@
|
|
|
872
872
|
"null",
|
|
873
873
|
"boolean"
|
|
874
874
|
]
|
|
875
|
+
},
|
|
876
|
+
"archived": {
|
|
877
|
+
"type": [
|
|
878
|
+
"null",
|
|
879
|
+
"boolean"
|
|
880
|
+
]
|
|
881
|
+
},
|
|
882
|
+
"default_access_level": {
|
|
883
|
+
"type": [
|
|
884
|
+
"null",
|
|
885
|
+
"string"
|
|
886
|
+
]
|
|
887
|
+
},
|
|
888
|
+
"privacy_setting": {
|
|
889
|
+
"type": [
|
|
890
|
+
"null",
|
|
891
|
+
"string"
|
|
892
|
+
]
|
|
893
|
+
},
|
|
894
|
+
"project_templates": {
|
|
895
|
+
"type": [
|
|
896
|
+
"null",
|
|
897
|
+
"array"
|
|
898
|
+
],
|
|
899
|
+
"items": {
|
|
900
|
+
"type": [
|
|
901
|
+
"null",
|
|
902
|
+
"object"
|
|
903
|
+
],
|
|
904
|
+
"additionalProperties": false,
|
|
905
|
+
"properties": {
|
|
906
|
+
"gid": {
|
|
907
|
+
"type": [
|
|
908
|
+
"null",
|
|
909
|
+
"string"
|
|
910
|
+
]
|
|
911
|
+
},
|
|
912
|
+
"resource_type": {
|
|
913
|
+
"type": [
|
|
914
|
+
"null",
|
|
915
|
+
"string"
|
|
916
|
+
]
|
|
917
|
+
},
|
|
918
|
+
"name": {
|
|
919
|
+
"type": [
|
|
920
|
+
"null",
|
|
921
|
+
"string"
|
|
922
|
+
]
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
875
926
|
}
|
|
876
927
|
}
|
|
877
928
|
}
|
|
@@ -106,6 +106,30 @@
|
|
|
106
106
|
"null",
|
|
107
107
|
"string"
|
|
108
108
|
]
|
|
109
|
+
},
|
|
110
|
+
"edit_team_name_or_description_access_level": {
|
|
111
|
+
"type": ["null", "string"]
|
|
112
|
+
},
|
|
113
|
+
"edit_team_visibility_or_trash_team_access_level": {
|
|
114
|
+
"type": ["null", "string"]
|
|
115
|
+
},
|
|
116
|
+
"member_invite_management_access_level": {
|
|
117
|
+
"type": ["null", "string"]
|
|
118
|
+
},
|
|
119
|
+
"guest_invite_management_access_level": {
|
|
120
|
+
"type": ["null", "string"]
|
|
121
|
+
},
|
|
122
|
+
"join_request_management_access_level": {
|
|
123
|
+
"type": ["null", "string"]
|
|
124
|
+
},
|
|
125
|
+
"team_member_removal_access_level": {
|
|
126
|
+
"type": ["null", "string"]
|
|
127
|
+
},
|
|
128
|
+
"team_content_management_access_level": {
|
|
129
|
+
"type": ["null", "string"]
|
|
130
|
+
},
|
|
131
|
+
"endorsed": {
|
|
132
|
+
"type": ["null", "boolean"]
|
|
109
133
|
}
|
|
110
134
|
}
|
|
111
135
|
}
|
|
@@ -2,18 +2,11 @@ import math
|
|
|
2
2
|
import functools
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
|
+
import asana
|
|
5
6
|
import requests
|
|
6
7
|
import backoff
|
|
7
8
|
import simplejson
|
|
8
9
|
import singer
|
|
9
|
-
from asana.error import (
|
|
10
|
-
NoAuthorizationError,
|
|
11
|
-
RetryableAsanaError,
|
|
12
|
-
InvalidTokenError,
|
|
13
|
-
RateLimitEnforcedError,
|
|
14
|
-
)
|
|
15
|
-
from asana.page_iterator import CollectionPageIterator
|
|
16
|
-
from oauthlib.oauth2 import TokenExpiredError
|
|
17
10
|
from singer import utils
|
|
18
11
|
from tap_asana.context import Context
|
|
19
12
|
|
|
@@ -61,14 +54,19 @@ def retry_handler(details):
|
|
|
61
54
|
|
|
62
55
|
# pylint: disable=unused-argument
|
|
63
56
|
def retry_after_wait_gen(**kwargs):
|
|
57
|
+
"""Generator to handle Retry-After logic for HTTP 429 errors."""
|
|
64
58
|
# This is called in an except block so we can retrieve the exception
|
|
65
59
|
# and check it.
|
|
66
60
|
exc_info = sys.exc_info()
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
if exc_info[1] is not None and hasattr(exc_info[1], "response"):
|
|
62
|
+
resp = exc_info[1].response
|
|
63
|
+
if resp.status_code == 429:
|
|
64
|
+
# Retry-After is an undocumented header. But honoring
|
|
65
|
+
# it was proven to work in our spikes.
|
|
66
|
+
sleep_time_str = resp.headers.get("Retry-After", "5")
|
|
67
|
+
yield math.floor(float(sleep_time_str))
|
|
68
|
+
else:
|
|
69
|
+
yield 0
|
|
72
70
|
|
|
73
71
|
|
|
74
72
|
def invalid_token_handler(details):
|
|
@@ -85,23 +83,22 @@ def asana_error_handling(fnc):
|
|
|
85
83
|
)
|
|
86
84
|
@backoff.on_exception(
|
|
87
85
|
backoff.expo,
|
|
88
|
-
(InvalidTokenError, NoAuthorizationError
|
|
86
|
+
(InvalidTokenError, NoAuthorizationError),
|
|
89
87
|
on_backoff=invalid_token_handler,
|
|
90
88
|
max_tries=MAX_RETRIES,
|
|
91
89
|
)
|
|
92
90
|
@backoff.on_exception(
|
|
93
91
|
backoff.expo,
|
|
94
|
-
(simplejson.scanner.JSONDecodeError,
|
|
92
|
+
(simplejson.scanner.JSONDecodeError, requests.exceptions.HTTPError),
|
|
95
93
|
giveup=is_not_status_code_fn(range(500, 599)),
|
|
96
94
|
on_backoff=retry_handler,
|
|
97
95
|
max_tries=MAX_RETRIES,
|
|
98
96
|
)
|
|
99
97
|
@backoff.on_exception(
|
|
100
98
|
retry_after_wait_gen,
|
|
101
|
-
|
|
99
|
+
(requests.exceptions.HTTPError, requests.exceptions.ConnectionError),
|
|
102
100
|
giveup=is_not_status_code_fn([429]),
|
|
103
101
|
on_backoff=leaky_bucket_handler,
|
|
104
|
-
# No jitter as we want a constant value
|
|
105
102
|
jitter=None,
|
|
106
103
|
)
|
|
107
104
|
@functools.wraps(fnc)
|
|
@@ -109,14 +106,24 @@ def asana_error_handling(fnc):
|
|
|
109
106
|
return fnc(*args, **kwargs)
|
|
110
107
|
return wrapper
|
|
111
108
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
|
|
110
|
+
class InvalidTokenError(Exception):
|
|
111
|
+
"""Custom exception for Invalid Token (Status Code 412)"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, response=None):
|
|
114
|
+
super().__init__("Invalid Token Error")
|
|
115
|
+
self.status_code = 412
|
|
116
|
+
self.response = response
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class NoAuthorizationError(Exception):
|
|
120
|
+
"""Custom exception for Unauthorized Access (Status Code 401)"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, response=None):
|
|
123
|
+
super().__init__("No Authorization Error")
|
|
124
|
+
self.status_code = 401
|
|
125
|
+
self.response = response
|
|
126
|
+
|
|
120
127
|
|
|
121
128
|
class Stream():
|
|
122
129
|
# Used for bookmarking and stream identification. Is overridden by
|
|
@@ -179,7 +186,7 @@ class Stream():
|
|
|
179
186
|
def get_updated_session_bookmark(session_bookmark, value):
|
|
180
187
|
"""Returns the updated session bookmark"""
|
|
181
188
|
try:
|
|
182
|
-
session_bookmark = utils.strptime_with_tz(session_bookmark)
|
|
189
|
+
session_bookmark = utils.strptime_with_tz(str(session_bookmark))
|
|
183
190
|
except TypeError:
|
|
184
191
|
pass
|
|
185
192
|
|
|
@@ -196,13 +203,75 @@ class Stream():
|
|
|
196
203
|
# hence removed the condition: 'if query_params', as
|
|
197
204
|
# there will be atleast 1 param: 'timeout'
|
|
198
205
|
@asana_error_handling
|
|
199
|
-
def call_api(self,
|
|
200
|
-
"""Function to make API call"""
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
def call_api(self, api_instance, method_name, **query_params):
|
|
207
|
+
"""Function to make API call with error handling"""
|
|
208
|
+
api_method = getattr(api_instance, method_name, None)
|
|
209
|
+
if not api_method:
|
|
210
|
+
raise AttributeError(f"Method '{method_name}' not found in {api_instance.__class__.__name__}.")
|
|
211
|
+
|
|
212
|
+
# Ensure the 'opts' parameter is included
|
|
213
|
+
if "opts" not in query_params:
|
|
214
|
+
query_params["opts"] = {}
|
|
215
|
+
|
|
216
|
+
# Call the API method without pagination
|
|
217
|
+
results = {"data": []}
|
|
218
|
+
try:
|
|
219
|
+
response = api_method(**query_params)
|
|
220
|
+
|
|
221
|
+
# Handle generator responses
|
|
222
|
+
if isinstance(response, (list, tuple)):
|
|
223
|
+
results["data"] = response
|
|
224
|
+
else:
|
|
225
|
+
results["data"] = list(response) # Convert generator to list
|
|
226
|
+
except asana.rest.ApiException as e:
|
|
227
|
+
if e.status == 412:
|
|
228
|
+
raise InvalidTokenError(response=e) from e
|
|
229
|
+
if e.status == 401:
|
|
230
|
+
raise NoAuthorizationError(response=e) from e
|
|
231
|
+
LOGGER.error("Error during API call: %s", e)
|
|
232
|
+
raise e from None
|
|
233
|
+
|
|
234
|
+
return results
|
|
235
|
+
|
|
236
|
+
# pylint: disable=use-yield-from
|
|
205
237
|
def sync(self):
|
|
206
238
|
"""Yield's processed SDK object dicts to the caller."""
|
|
207
239
|
for obj in self.get_objects():
|
|
208
240
|
yield obj
|
|
241
|
+
|
|
242
|
+
@asana_error_handling
|
|
243
|
+
def fetch_workspaces(self, opts=None):
|
|
244
|
+
"""Fetch all workspaces using the Asana API."""
|
|
245
|
+
if opts is None: # Initialize opts as an empty dictionary if not provided
|
|
246
|
+
opts = {}
|
|
247
|
+
result = []
|
|
248
|
+
try:
|
|
249
|
+
workspaces_api = asana.WorkspacesApi(Context.asana.client)
|
|
250
|
+
response = list(workspaces_api.get_workspaces(opts=opts))
|
|
251
|
+
result = response
|
|
252
|
+
except asana.rest.ApiException as e:
|
|
253
|
+
if e.status == 412:
|
|
254
|
+
raise InvalidTokenError(response=e) from e
|
|
255
|
+
if e.status == 401:
|
|
256
|
+
raise NoAuthorizationError(response=e) from e
|
|
257
|
+
LOGGER.error("Failed to fetch workspaces: %s", e)
|
|
258
|
+
raise e from None
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
@asana_error_handling
|
|
262
|
+
def fetch_projects(self, workspace_gid, opt_fields, request_timeout):
|
|
263
|
+
"""Fetch all projects using the Asana API."""
|
|
264
|
+
result = []
|
|
265
|
+
try:
|
|
266
|
+
projects_api = asana.ProjectsApi(Context.asana.client)
|
|
267
|
+
response = projects_api.get_projects(opts={"workspace": workspace_gid, "opt_fields": opt_fields},
|
|
268
|
+
_request_timeout=request_timeout)
|
|
269
|
+
result = list(response)
|
|
270
|
+
except asana.rest.ApiException as e:
|
|
271
|
+
if e.status == 412:
|
|
272
|
+
raise InvalidTokenError(response=e) from e
|
|
273
|
+
if e.status == 401:
|
|
274
|
+
raise NoAuthorizationError(response=e) from e
|
|
275
|
+
LOGGER.error("Failed to fetch projects: %s", e)
|
|
276
|
+
raise e from None
|
|
277
|
+
return result
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import asana
|
|
2
|
+
from tap_asana.context import Context
|
|
3
|
+
from tap_asana.streams.base import Stream
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Portfolios(Stream):
|
|
7
|
+
name = "portfolios"
|
|
8
|
+
replication_method = "FULL_TABLE"
|
|
9
|
+
|
|
10
|
+
fields = [
|
|
11
|
+
"gid",
|
|
12
|
+
"resource_type",
|
|
13
|
+
"name",
|
|
14
|
+
"color",
|
|
15
|
+
"created_at",
|
|
16
|
+
"created_by",
|
|
17
|
+
"custom_field_settings",
|
|
18
|
+
"is_template",
|
|
19
|
+
"due_on",
|
|
20
|
+
"members",
|
|
21
|
+
"owner",
|
|
22
|
+
"permalink_url",
|
|
23
|
+
"start_on",
|
|
24
|
+
"workspace",
|
|
25
|
+
"portfolio_items",
|
|
26
|
+
"current_status_update",
|
|
27
|
+
"custom_fields",
|
|
28
|
+
"public"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
def get_objects(self):
|
|
32
|
+
"""Get stream object"""
|
|
33
|
+
opt_fields = ",".join(self.fields)
|
|
34
|
+
workspaces = self.fetch_workspaces()
|
|
35
|
+
|
|
36
|
+
# Use WorkspacesApi to fetch workspaces
|
|
37
|
+
portfolios_api = asana.PortfoliosApi(Context.asana.client)
|
|
38
|
+
|
|
39
|
+
for workspace in workspaces:
|
|
40
|
+
# Paginate through portfolios for each workspace
|
|
41
|
+
offset = True
|
|
42
|
+
while True:
|
|
43
|
+
# Fetch portfolios for the current workspace using call_api
|
|
44
|
+
response = self.call_api(
|
|
45
|
+
portfolios_api,
|
|
46
|
+
"get_portfolios",
|
|
47
|
+
workspace=workspace["gid"], # Workspace ID
|
|
48
|
+
opts={"owner": "me", "opt_fields": opt_fields},
|
|
49
|
+
_request_timeout=self.request_timeout,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
for portfolio in response["data"]:
|
|
53
|
+
# Fetch detailed portfolio information using get_portfolio
|
|
54
|
+
portfolio_details = self.call_api(
|
|
55
|
+
portfolios_api,
|
|
56
|
+
"get_portfolio",
|
|
57
|
+
portfolio_gid=portfolio["gid"],
|
|
58
|
+
opts={"opt_fields": opt_fields},
|
|
59
|
+
_request_timeout=self.request_timeout,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Add detailed portfolio information to the portfolio object
|
|
63
|
+
portfolio.update(portfolio_details)
|
|
64
|
+
yield portfolio
|
|
65
|
+
|
|
66
|
+
# Check if there are more pages
|
|
67
|
+
offset = response.get("offset")
|
|
68
|
+
if not offset:
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
Context.stream_objects["portfolios"] = Portfolios
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asana
|
|
1
2
|
from tap_asana.context import Context
|
|
2
3
|
from tap_asana.streams.base import Stream
|
|
3
4
|
|
|
@@ -45,11 +46,34 @@ class Projects(Stream):
|
|
|
45
46
|
opt_fields = ",".join(self.fields)
|
|
46
47
|
bookmark = self.get_bookmark()
|
|
47
48
|
session_bookmark = bookmark
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
|
|
50
|
+
workspaces = self.fetch_workspaces()
|
|
51
|
+
|
|
52
|
+
# Use ProjectsApi to fetch projects
|
|
53
|
+
projects_api = asana.ProjectsApi(Context.asana.client)
|
|
54
|
+
|
|
55
|
+
for workspace in workspaces:
|
|
56
|
+
# Paginate through projects for each workspace
|
|
57
|
+
offset = None
|
|
58
|
+
while True:
|
|
59
|
+
response = self.call_api(
|
|
60
|
+
projects_api,
|
|
61
|
+
"get_projects",
|
|
62
|
+
opts={"workspace": workspace["gid"], "opt_fields": opt_fields},
|
|
63
|
+
_request_timeout=self.request_timeout,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
for project in response["data"]:
|
|
67
|
+
session_bookmark = self.get_updated_session_bookmark(session_bookmark, project[self.replication_key])
|
|
68
|
+
if self.is_bookmark_old(project[self.replication_key]):
|
|
69
|
+
yield project
|
|
70
|
+
|
|
71
|
+
# Check if there are more pages
|
|
72
|
+
offset = response.get("offset")
|
|
73
|
+
if not offset:
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
# Update the bookmark after processing all projects
|
|
53
77
|
self.update_bookmark(session_bookmark)
|
|
54
78
|
|
|
55
79
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import asana
|
|
2
|
+
from tap_asana.context import Context
|
|
3
|
+
from tap_asana.streams.base import Stream
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# pylint: disable=use-yield-from
|
|
7
|
+
class Sections(Stream):
|
|
8
|
+
replication_method = "FULL_TABLE"
|
|
9
|
+
name = "sections"
|
|
10
|
+
|
|
11
|
+
fields = [
|
|
12
|
+
"gid",
|
|
13
|
+
"resource_type",
|
|
14
|
+
"name",
|
|
15
|
+
"created_at",
|
|
16
|
+
"project",
|
|
17
|
+
"projects"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
def get_objects(self):
|
|
21
|
+
"""Get stream object"""
|
|
22
|
+
opt_fields = ",".join(self.fields)
|
|
23
|
+
workspaces = self.fetch_workspaces()
|
|
24
|
+
|
|
25
|
+
# Use SectionsApi to fetch sections
|
|
26
|
+
sections_api = asana.SectionsApi(Context.asana.client)
|
|
27
|
+
|
|
28
|
+
# Iterate over all workspaces
|
|
29
|
+
for workspace in workspaces:
|
|
30
|
+
response = self.fetch_projects(workspace_gid=workspace["gid"], opt_fields=opt_fields,
|
|
31
|
+
request_timeout=self.request_timeout)
|
|
32
|
+
project_ids = [project["gid"] for project in response]
|
|
33
|
+
|
|
34
|
+
# Iterate over all project IDs and fetch sections
|
|
35
|
+
for project_id in project_ids:
|
|
36
|
+
section_response = self.call_api(
|
|
37
|
+
sections_api,
|
|
38
|
+
"get_sections_for_project",
|
|
39
|
+
project_gid=project_id,
|
|
40
|
+
opts={"opt_fields": opt_fields},
|
|
41
|
+
_request_timeout=self.request_timeout,
|
|
42
|
+
)
|
|
43
|
+
for section in section_response["data"]:
|
|
44
|
+
yield section
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Context.stream_objects["sections"] = Sections
|