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.
Files changed (49) hide show
  1. {tap-asana-2.3.0/tap_asana.egg-info → tap_asana-2.4.0}/PKG-INFO +12 -6
  2. {tap-asana-2.3.0 → tap_asana-2.4.0}/setup.py +8 -9
  3. tap_asana-2.4.0/tap_asana/asana.py +67 -0
  4. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/portfolios.json +53 -2
  5. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/projects.json +1 -1
  6. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/stories.json +6 -0
  7. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/teams.json +24 -0
  8. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/base.py +101 -32
  9. tap_asana-2.4.0/tap_asana/streams/portfolios.py +72 -0
  10. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/projects.py +29 -5
  11. tap_asana-2.4.0/tap_asana/streams/sections.py +47 -0
  12. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/stories.py +29 -19
  13. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/subtasks.py +28 -18
  14. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/tags.py +19 -5
  15. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/tasks.py +28 -18
  16. tap_asana-2.4.0/tap_asana/streams/teams.py +54 -0
  17. tap_asana-2.4.0/tap_asana/streams/users.py +40 -0
  18. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/workspaces.py +13 -1
  19. {tap-asana-2.3.0 → tap_asana-2.4.0/tap_asana.egg-info}/PKG-INFO +12 -6
  20. tap_asana-2.4.0/tap_asana.egg-info/requires.txt +9 -0
  21. {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_all_fields.py +21 -4
  22. {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_automatic_fields.py +2 -1
  23. {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_bookmarks.py +2 -1
  24. {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_start_date.py +2 -1
  25. tap-asana-2.3.0/tap_asana/asana.py +0 -57
  26. tap-asana-2.3.0/tap_asana/streams/portfolios.py +0 -53
  27. tap-asana-2.3.0/tap_asana/streams/sections.py +0 -40
  28. tap-asana-2.3.0/tap_asana/streams/teams.py +0 -39
  29. tap-asana-2.3.0/tap_asana/streams/users.py +0 -28
  30. tap-asana-2.3.0/tap_asana.egg-info/requires.txt +0 -10
  31. {tap-asana-2.3.0 → tap_asana-2.4.0}/LICENSE +0 -0
  32. {tap-asana-2.3.0 → tap_asana-2.4.0}/MANIFEST.in +0 -0
  33. {tap-asana-2.3.0 → tap_asana-2.4.0}/README.md +0 -0
  34. {tap-asana-2.3.0 → tap_asana-2.4.0}/setup.cfg +0 -0
  35. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/__init__.py +0 -0
  36. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/context.py +0 -0
  37. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/sections.json +0 -0
  38. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/subtasks.json +0 -0
  39. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/tags.json +0 -0
  40. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/tasks.json +0 -0
  41. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/users.json +0 -0
  42. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/schemas/workspaces.json +0 -0
  43. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana/streams/__init__.py +0 -0
  44. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana.egg-info/SOURCES.txt +0 -0
  45. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana.egg-info/dependency_links.txt +0 -0
  46. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana.egg-info/entry_points.txt +0 -0
  47. {tap-asana-2.3.0 → tap_asana-2.4.0}/tap_asana.egg-info/top_level.txt +0 -0
  48. {tap-asana-2.3.0 → tap_asana-2.4.0}/tests/test_discovery.py +0 -0
  49. {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
1
+ Metadata-Version: 2.4
2
2
  Name: tap-asana
3
- Version: 2.3.0
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==3.1.0
10
- Requires-Dist: singer-python==5.13.0
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.3.0",
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==3.1.0",
14
- 'singer-python==5.13.0'
13
+ "asana==5.1.0",
14
+ "requests==2.32.4",
15
+ "singer-python==6.1.1"
15
16
  ],
16
17
  extras_require={
17
- 'test': [
18
- 'pylint',
19
- 'requests==2.20.0',
20
- 'nose'
18
+ "test": [
19
+ "pylint"
21
20
  ],
22
- 'dev': [
23
- 'ipdb'
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
  }
@@ -1218,6 +1218,12 @@
1218
1218
  "null",
1219
1219
  "string"
1220
1220
  ]
1221
+ },
1222
+ "new_approval_status_updated": {
1223
+ "type": [
1224
+ "null",
1225
+ "string"
1226
+ ]
1221
1227
  }
1222
1228
  }
1223
1229
  }
@@ -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
- resp = exc_info[1].response
68
- # Retry-After is an undocumented header. But honoring
69
- # it was proven to work in our spikes.
70
- sleep_time_str = resp.headers.get("Retry-After")
71
- yield math.floor(float(sleep_time_str))
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, TokenExpiredError),
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, RetryableAsanaError),
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
- RateLimitEnforcedError,
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
- # Added decorator over functions of asana SDK as functions from SDK returns generator and
113
- # tap is yielding data from that function so backoff is not working over tap functions.
114
- # Decorator can be put above get_objects() functions of every stream file but
115
- # it has multiple for loops so it's expensive to backoff everything.
116
- CollectionPageIterator.get_initial = asana_error_handling(
117
- CollectionPageIterator.get_initial
118
- )
119
- CollectionPageIterator.get_next = asana_error_handling(CollectionPageIterator.get_next)
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, resource, **query_params):
200
- """Function to make API call"""
201
- api_function = getattr(Context.asana.client, resource)
202
- query_params["timeout"] = self.request_timeout
203
- return api_function.find_all(**query_params)
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
- for workspace in self.call_api("workspaces"):
49
- for project in self.call_api("projects", workspace=workspace["gid"], opt_fields=opt_fields):
50
- session_bookmark = self.get_updated_session_bookmark(session_bookmark, project[self.replication_key])
51
- if self.is_bookmark_old(project[self.replication_key]):
52
- yield project
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