@sw-tsdk/plugin-connector 3.13.1 → 3.13.2-next.3dfd44a
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.
- package/README.md +18 -18
- package/lib/commands/connector/build.js +168 -44
- package/lib/commands/connector/build.js.map +1 -1
- package/lib/commands/connector/sign.js +108 -12
- package/lib/commands/connector/sign.js.map +1 -1
- package/lib/commands/connector/validate.js +110 -10
- package/lib/commands/connector/validate.js.map +1 -1
- package/lib/commands/migrator/convert.d.ts +3 -0
- package/lib/commands/migrator/convert.js +201 -20
- package/lib/commands/migrator/convert.js.map +1 -1
- package/lib/templates/migrator-runners/plugin_override.txt +76 -4
- package/lib/templates/migrator-runners/runner_override.txt +30 -0
- package/lib/templates/migrator-runners/script_override.txt +77 -5
- package/lib/templates/swimlane/__init__.py +18 -0
- package/lib/templates/swimlane/core/__init__.py +0 -0
- package/lib/templates/swimlane/core/adapters/__init__.py +10 -0
- package/lib/templates/swimlane/core/adapters/app.py +59 -0
- package/lib/templates/swimlane/core/adapters/app_revision.py +49 -0
- package/lib/templates/swimlane/core/adapters/helper.py +84 -0
- package/lib/templates/swimlane/core/adapters/record.py +468 -0
- package/lib/templates/swimlane/core/adapters/record_revision.py +43 -0
- package/lib/templates/swimlane/core/adapters/report.py +65 -0
- package/lib/templates/swimlane/core/adapters/task.py +58 -0
- package/lib/templates/swimlane/core/adapters/usergroup.py +183 -0
- package/lib/templates/swimlane/core/bulk.py +48 -0
- package/lib/templates/swimlane/core/cache.py +165 -0
- package/lib/templates/swimlane/core/client.py +466 -0
- package/lib/templates/swimlane/core/cursor.py +100 -0
- package/lib/templates/swimlane/core/fields/__init__.py +46 -0
- package/lib/templates/swimlane/core/fields/attachment.py +82 -0
- package/lib/templates/swimlane/core/fields/base/__init__.py +15 -0
- package/lib/templates/swimlane/core/fields/base/cursor.py +90 -0
- package/lib/templates/swimlane/core/fields/base/field.py +149 -0
- package/lib/templates/swimlane/core/fields/base/multiselect.py +116 -0
- package/lib/templates/swimlane/core/fields/comment.py +48 -0
- package/lib/templates/swimlane/core/fields/datetime.py +112 -0
- package/lib/templates/swimlane/core/fields/history.py +28 -0
- package/lib/templates/swimlane/core/fields/list.py +266 -0
- package/lib/templates/swimlane/core/fields/number.py +38 -0
- package/lib/templates/swimlane/core/fields/reference.py +169 -0
- package/lib/templates/swimlane/core/fields/text.py +30 -0
- package/lib/templates/swimlane/core/fields/tracking.py +10 -0
- package/lib/templates/swimlane/core/fields/usergroup.py +137 -0
- package/lib/templates/swimlane/core/fields/valueslist.py +70 -0
- package/lib/templates/swimlane/core/resolver.py +46 -0
- package/lib/templates/swimlane/core/resources/__init__.py +0 -0
- package/lib/templates/swimlane/core/resources/app.py +136 -0
- package/lib/templates/swimlane/core/resources/app_revision.py +43 -0
- package/lib/templates/swimlane/core/resources/attachment.py +64 -0
- package/lib/templates/swimlane/core/resources/base.py +55 -0
- package/lib/templates/swimlane/core/resources/comment.py +33 -0
- package/lib/templates/swimlane/core/resources/record.py +499 -0
- package/lib/templates/swimlane/core/resources/record_revision.py +44 -0
- package/lib/templates/swimlane/core/resources/report.py +259 -0
- package/lib/templates/swimlane/core/resources/revision_base.py +69 -0
- package/lib/templates/swimlane/core/resources/task.py +16 -0
- package/lib/templates/swimlane/core/resources/usergroup.py +166 -0
- package/lib/templates/swimlane/core/search.py +31 -0
- package/lib/templates/swimlane/core/wrappedsession.py +12 -0
- package/lib/templates/swimlane/exceptions.py +191 -0
- package/lib/templates/swimlane/utils/__init__.py +132 -0
- package/lib/templates/swimlane/utils/date_validator.py +4 -0
- package/lib/templates/swimlane/utils/list_validator.py +7 -0
- package/lib/templates/swimlane/utils/str_validator.py +10 -0
- package/lib/templates/swimlane/utils/version.py +101 -0
- package/lib/transformers/base-transformer.js +61 -14
- package/lib/transformers/base-transformer.js.map +1 -1
- package/lib/transformers/connector-generator.d.ts +104 -2
- package/lib/transformers/connector-generator.js +1234 -51
- package/lib/transformers/connector-generator.js.map +1 -1
- package/lib/types/migrator-types.d.ts +22 -0
- package/lib/types/migrator-types.js.map +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Core Swimlane client class"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
import pendulum
|
|
7
|
+
import requests
|
|
8
|
+
import time
|
|
9
|
+
from pyuri import URI
|
|
10
|
+
from requests.compat import json
|
|
11
|
+
from requests.packages import urllib3
|
|
12
|
+
from requests.structures import CaseInsensitiveDict
|
|
13
|
+
from requests.exceptions import ConnectionError
|
|
14
|
+
from six.moves.urllib.parse import urljoin
|
|
15
|
+
|
|
16
|
+
from swimlane.core.adapters import GroupAdapter, UserAdapter, AppAdapter, HelperAdapter
|
|
17
|
+
from swimlane.core.cache import ResourcesCache
|
|
18
|
+
from swimlane.core.resolver import SwimlaneResolver
|
|
19
|
+
from swimlane.core.resources.usergroup import User
|
|
20
|
+
from swimlane.exceptions import SwimlaneHTTP400Error, InvalidSwimlaneProductVersion
|
|
21
|
+
from swimlane.utils.version import get_package_version, compare_versions
|
|
22
|
+
from swimlane.core.wrappedsession import WrappedSession
|
|
23
|
+
|
|
24
|
+
# Disable insecure request warnings
|
|
25
|
+
urllib3.disable_warnings()
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# pylint: disable=invalid-name
|
|
30
|
+
_lib_full_version = get_package_version()
|
|
31
|
+
_lib_major_version, _lib_minor_version = _lib_full_version.split('.')[0:2]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Swimlane(object):
|
|
35
|
+
"""Swimlane API client
|
|
36
|
+
|
|
37
|
+
Core class used throughout library for all API requests and server interactions
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
host (str): Full RFC-1738 URL pointing to Swimlane host. Defaults will be provided for all parts
|
|
41
|
+
username (str): Authentication username
|
|
42
|
+
password (str): Authentication password
|
|
43
|
+
verify_ssl (bool): Verify SSL (ignored on HTTP). Disable to use self-signed certificates
|
|
44
|
+
default_timeout (int): Default request connect and read timeout in seconds for all requests
|
|
45
|
+
verify_server_version (bool): Verify server version has same major version as client package. May require
|
|
46
|
+
additional requests, set False to disable check
|
|
47
|
+
resource_cache_size (int): Maximum number of each resource type to keep in memory cache. Set 0 to disable
|
|
48
|
+
caching. Disabled by default
|
|
49
|
+
access_token (str): Authentication token, used in lieu of a username and password
|
|
50
|
+
write_to_read_only (bool): Enable the ability to write to Read-only fields
|
|
51
|
+
retry (bool): Retry request when error code is >= 500
|
|
52
|
+
max_retries (int): Maximum number of retry attempts
|
|
53
|
+
retry_interval (int): Time interval (in seconds) between two retry attempts
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
host (pyuri.URI): Full RFC-1738 URL pointing to Swimlane host
|
|
57
|
+
apps (AppAdapter): :class:`~swimlane.core.adapters.app.AppAdapter` configured for current Swimlane instance
|
|
58
|
+
users (UserAdapter): :class:`~swimlane.core.adapters.usergroup.UserAdapter` configured for current
|
|
59
|
+
Swimlane instance
|
|
60
|
+
groups (GroupAdapter): :class:`~swimlane.core.adapters.usergroup.GroupAdapter` configured for current
|
|
61
|
+
Swimlane instance
|
|
62
|
+
resources_cache (ResourcesCache): Cache checked by all supported adapters for current Swimlane instance
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
|
|
66
|
+
::
|
|
67
|
+
|
|
68
|
+
# Establish connection using username password
|
|
69
|
+
swimlane = Swimlane(
|
|
70
|
+
'https://192.168.1.1',
|
|
71
|
+
'username',
|
|
72
|
+
'password',
|
|
73
|
+
verify_ssl=False
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Or establish connection using personal access token
|
|
77
|
+
swimlane = Swimlane(
|
|
78
|
+
'https://192.168.1.1',
|
|
79
|
+
access_token='abcdefg',
|
|
80
|
+
verify_ssl=False
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Retrieve an app
|
|
84
|
+
app = swimlane.apps.get(name='Target App')
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
#_api_root = '/api/''
|
|
89
|
+
# Turbine variables
|
|
90
|
+
turbineAccountId = ""
|
|
91
|
+
turbineTenantId = ""
|
|
92
|
+
_api_root = f'/api/account/{turbineAccountId}/tenant/{turbineTenantId}/'
|
|
93
|
+
_execute_task_webhook_url = ""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
host,
|
|
98
|
+
username=None,
|
|
99
|
+
password=None,
|
|
100
|
+
verify_ssl=True,
|
|
101
|
+
default_timeout=60,
|
|
102
|
+
verify_server_version=True,
|
|
103
|
+
resource_cache_size=0,
|
|
104
|
+
access_token=None,
|
|
105
|
+
write_to_read_only: bool=False,
|
|
106
|
+
retry: bool=True,
|
|
107
|
+
max_retries: int=5,
|
|
108
|
+
retry_interval: int=5
|
|
109
|
+
):
|
|
110
|
+
self.__verify_auth_params(username, password, access_token)
|
|
111
|
+
|
|
112
|
+
self.host = URI(host)
|
|
113
|
+
self.host.scheme = (self.host.scheme or 'https').lower()
|
|
114
|
+
self.host.path = None
|
|
115
|
+
|
|
116
|
+
self.resources_cache = ResourcesCache(resource_cache_size)
|
|
117
|
+
|
|
118
|
+
self.__settings = None
|
|
119
|
+
self.__user = None
|
|
120
|
+
|
|
121
|
+
self._write_to_read_only = write_to_read_only
|
|
122
|
+
|
|
123
|
+
self._default_timeout = default_timeout
|
|
124
|
+
|
|
125
|
+
self._session = WrappedSession()
|
|
126
|
+
self._session.verify = verify_ssl
|
|
127
|
+
|
|
128
|
+
self.retry = retry
|
|
129
|
+
self.max_retries = max_retries
|
|
130
|
+
self.retry_interval = retry_interval
|
|
131
|
+
|
|
132
|
+
if username is not None and password is not None:
|
|
133
|
+
self._session.auth = SwimlaneJwtAuth(
|
|
134
|
+
self,
|
|
135
|
+
username,
|
|
136
|
+
password
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
self._session.auth = SwimlaneTokenAuth(
|
|
140
|
+
self,
|
|
141
|
+
access_token
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self.apps = AppAdapter(self)
|
|
145
|
+
self.users = UserAdapter(self)
|
|
146
|
+
self.groups = GroupAdapter(self)
|
|
147
|
+
self.helpers = HelperAdapter(self)
|
|
148
|
+
|
|
149
|
+
#if verify_server_version:
|
|
150
|
+
# self.__verify_server_version()
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def __verify_auth_params(username, password, access_token):
|
|
154
|
+
"""Verify that valid authentication parameters were passed to __init__"""
|
|
155
|
+
|
|
156
|
+
if all(v is not None for v in [username, password, access_token]):
|
|
157
|
+
raise ValueError('Cannot supply a username/password and a access token')
|
|
158
|
+
|
|
159
|
+
if (username is None or password is None) and access_token is None:
|
|
160
|
+
raise ValueError('Must supply a username/password or access token')
|
|
161
|
+
|
|
162
|
+
def __verify_server_version(self):
|
|
163
|
+
"""Verify connected to supported server product version
|
|
164
|
+
|
|
165
|
+
Notes:
|
|
166
|
+
Logs warning if connecting to a newer minor server version
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
swimlane.exceptions.InvalidServerVersion: If server major version is higher than package major version
|
|
170
|
+
"""
|
|
171
|
+
if compare_versions('.'.join([_lib_major_version, _lib_minor_version]), self.product_version) > 0:
|
|
172
|
+
logger.warning('Client version {} connecting to server with newer minor release {}.'.format(
|
|
173
|
+
_lib_full_version,
|
|
174
|
+
self.product_version
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
if compare_versions(_lib_major_version, self.product_version) != 0:
|
|
178
|
+
raise InvalidSwimlaneProductVersion(
|
|
179
|
+
self,
|
|
180
|
+
'{}.0'.format(_lib_major_version),
|
|
181
|
+
'{}.0'.format(str(int(_lib_major_version) + 1))
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def __repr__(self):
|
|
185
|
+
return '<{cls}: {user} @ {host} v{version}>'.format(
|
|
186
|
+
cls=self.__class__.__name__,
|
|
187
|
+
user=self.user,
|
|
188
|
+
host=self.host,
|
|
189
|
+
version=self.version
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def request(self, method, api_endpoint, **kwargs):
|
|
193
|
+
"""Wrapper for underlying :class:`requests.Session`
|
|
194
|
+
|
|
195
|
+
Handles generating full API URL, session reuse and auth, request defaults, and invalid response status codes
|
|
196
|
+
|
|
197
|
+
Used throughout library as the core underlying request/response method for all interactions with server
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
method (str): Request method (get, post, put, etc.)
|
|
201
|
+
api_endpoint (str): Portion of URL matching API endpoint route as listed in platform /docs help page
|
|
202
|
+
**kwargs (dict): Remaining arguments passed through to actual request call
|
|
203
|
+
|
|
204
|
+
Notes:
|
|
205
|
+
All other provided kwargs are passed to underlying ``requests.Session.request()`` call
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
swimlane.exceptions.SwimlaneHTTP400Error: On 400 responses with additional context about the exception
|
|
209
|
+
requests.HTTPError: Any other 4xx/5xx HTTP responses
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
requests.Response: Successful response instances
|
|
213
|
+
|
|
214
|
+
Examples:
|
|
215
|
+
|
|
216
|
+
Request and parse server settings endpoint response
|
|
217
|
+
|
|
218
|
+
>>> server_settings = swimlane.request('get', 'settings').json()
|
|
219
|
+
"""
|
|
220
|
+
while api_endpoint.startswith('/'):
|
|
221
|
+
api_endpoint = api_endpoint[1:]
|
|
222
|
+
|
|
223
|
+
# Ensure a timeout is set
|
|
224
|
+
kwargs.setdefault('timeout', self._default_timeout)
|
|
225
|
+
|
|
226
|
+
# Manually grab and dump json data to have full control over serialization
|
|
227
|
+
# Emulate default requests behavior
|
|
228
|
+
json_data = kwargs.pop('json', None)
|
|
229
|
+
if json_data is not None:
|
|
230
|
+
headers = CaseInsensitiveDict(kwargs.get('headers', {}))
|
|
231
|
+
headers.setdefault('Content-Type', 'application/json')
|
|
232
|
+
kwargs['headers'] = headers
|
|
233
|
+
|
|
234
|
+
kwargs['data'] = json.dumps(json_data, sort_keys=True, separators=(',', ':'))
|
|
235
|
+
|
|
236
|
+
# Retry logic
|
|
237
|
+
req_retry = kwargs.pop('retry', self.retry)
|
|
238
|
+
|
|
239
|
+
req_max_retries = kwargs.pop('max_retries', self.max_retries)
|
|
240
|
+
if not isinstance(req_max_retries, int):
|
|
241
|
+
raise TypeError('max_retries should be an integer')
|
|
242
|
+
if req_max_retries <= 0:
|
|
243
|
+
raise ValueError('max_retries should be a positive integer')
|
|
244
|
+
|
|
245
|
+
req_retry_interval = kwargs.pop('retry_interval', self.retry_interval)
|
|
246
|
+
if not isinstance(req_retry_interval, int):
|
|
247
|
+
raise TypeError('retry_interval should be an integer')
|
|
248
|
+
if req_retry_interval <= 0:
|
|
249
|
+
raise ValueError('retry_interval should be a positive integer')
|
|
250
|
+
|
|
251
|
+
while not req_max_retries<0:
|
|
252
|
+
if api_endpoint == 'tenant/api/users/login':
|
|
253
|
+
response = self._session.request(method, urljoin(str(self.host), api_endpoint), **kwargs)
|
|
254
|
+
else:
|
|
255
|
+
response = self._session.request(method, urljoin(str(self.host) + self._api_root, api_endpoint), **kwargs)
|
|
256
|
+
|
|
257
|
+
# Roll 400 errors up into SwimlaneHTTP400Errors with specific Swimlane error code support
|
|
258
|
+
try:
|
|
259
|
+
response.raise_for_status()
|
|
260
|
+
# Exit loop on successful request
|
|
261
|
+
req_max_retries = -1
|
|
262
|
+
except requests.HTTPError as error:
|
|
263
|
+
if error.response.status_code == 400:
|
|
264
|
+
raise SwimlaneHTTP400Error(error)
|
|
265
|
+
else:
|
|
266
|
+
if req_retry and req_max_retries>0 and error.response.status_code>=500:
|
|
267
|
+
req_max_retries -= 1
|
|
268
|
+
time.sleep(req_retry_interval)
|
|
269
|
+
continue
|
|
270
|
+
elif req_max_retries == 0:
|
|
271
|
+
raise ConnectionError(f'Max retries exceeded. Caused by ({error})')
|
|
272
|
+
raise error
|
|
273
|
+
|
|
274
|
+
return response
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def settings(self):
|
|
278
|
+
"""Retrieve and cache settings from server"""
|
|
279
|
+
if not self.__settings:
|
|
280
|
+
self.__settings = self.request('get', 'settings').json()
|
|
281
|
+
return self.__settings
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def version(self):
|
|
285
|
+
"""Full Swimlane version, <product_version>+<build_version>+<build_number>"""
|
|
286
|
+
return self.settings['apiVersion']
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def product_version(self):
|
|
290
|
+
"""Swimlane product version"""
|
|
291
|
+
version_separator = '+'
|
|
292
|
+
if version_separator in self.version:
|
|
293
|
+
# Post product/build version separation
|
|
294
|
+
return self.version.split(version_separator)[0]
|
|
295
|
+
# Pre product/build version separation
|
|
296
|
+
return self.version.split('-')[0]
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def build_version(self):
|
|
300
|
+
"""Swimlane semantic build version
|
|
301
|
+
|
|
302
|
+
Falls back to product version in pre-2.18 releases
|
|
303
|
+
"""
|
|
304
|
+
version_separator = '+'
|
|
305
|
+
if version_separator in self.version:
|
|
306
|
+
# Post product/build version separation
|
|
307
|
+
# This will handle <product_version>+<build_version>+<build_number>
|
|
308
|
+
# or <build_version>+<build_number> formats of the version
|
|
309
|
+
return self.version.split(version_separator)[-2]
|
|
310
|
+
# Pre product/build version separation
|
|
311
|
+
return self.product_version
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def build_number(self):
|
|
315
|
+
"""Swimlane build number"""
|
|
316
|
+
version_separator = '+'
|
|
317
|
+
if version_separator in self.version:
|
|
318
|
+
# Post product/build version separation
|
|
319
|
+
return self.version.split(version_separator)[2]
|
|
320
|
+
# Pre product/build version separation
|
|
321
|
+
return self.version.split('-')[1]
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def user(self):
|
|
325
|
+
"""User record instance for authenticated user"""
|
|
326
|
+
return self._session.auth.user
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class SwimlaneTokenAuth(SwimlaneResolver):
|
|
330
|
+
"""Handles token authentication for all requests
|
|
331
|
+
|
|
332
|
+
.. versionadded:: 4.1.0
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
def __init__(self, swimlane, access_token):
|
|
336
|
+
super(SwimlaneTokenAuth, self).__init__(swimlane)
|
|
337
|
+
|
|
338
|
+
self._access_token = access_token
|
|
339
|
+
self.user = None
|
|
340
|
+
|
|
341
|
+
def __call__(self, request):
|
|
342
|
+
"""Attach necessary headers to all requests"""
|
|
343
|
+
|
|
344
|
+
headers = {
|
|
345
|
+
'Private-Token': self._access_token
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
request.headers.update(headers)
|
|
349
|
+
|
|
350
|
+
# Only make the call to user/authorize to get the user's profile if we haven't retrieved it
|
|
351
|
+
# already
|
|
352
|
+
if self.user is not None:
|
|
353
|
+
return request
|
|
354
|
+
|
|
355
|
+
# Temporarily remove auth from Swimlane session for auth request to avoid recursive loop during the request
|
|
356
|
+
self._swimlane._session.auth = None
|
|
357
|
+
resp = self._swimlane.request(
|
|
358
|
+
'get',
|
|
359
|
+
'user/authorize',
|
|
360
|
+
headers=headers
|
|
361
|
+
)
|
|
362
|
+
self._swimlane._session.auth = self
|
|
363
|
+
|
|
364
|
+
json_content = resp.json()
|
|
365
|
+
self.user = User(self._swimlane, _user_raw_from_login_content(json_content))
|
|
366
|
+
|
|
367
|
+
return request
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class SwimlaneJwtAuth(SwimlaneResolver):
|
|
371
|
+
"""Handles authentication for all requests"""
|
|
372
|
+
|
|
373
|
+
_token_expiration_buffer = pendulum.Duration(minutes=5)
|
|
374
|
+
|
|
375
|
+
def __init__(self, swimlane, username, password):
|
|
376
|
+
super(SwimlaneJwtAuth, self).__init__(swimlane)
|
|
377
|
+
|
|
378
|
+
self._username = username
|
|
379
|
+
self._password = password
|
|
380
|
+
|
|
381
|
+
self.user = None
|
|
382
|
+
self._login_headers = {}
|
|
383
|
+
self._token_expiration = pendulum.now()
|
|
384
|
+
|
|
385
|
+
def __call__(self, request):
|
|
386
|
+
"""Attach necessary headers to all requests
|
|
387
|
+
|
|
388
|
+
Automatically reauthenticate before sending request when nearing token expiration
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
# Refresh token if it expires soon
|
|
392
|
+
if pendulum.now() + self._token_expiration_buffer >= self._token_expiration:
|
|
393
|
+
self.authenticate()
|
|
394
|
+
|
|
395
|
+
request.headers.update(self._login_headers)
|
|
396
|
+
|
|
397
|
+
return request
|
|
398
|
+
|
|
399
|
+
def authenticate(self):
|
|
400
|
+
"""Send login request and update User instance, login headers, and token expiration"""
|
|
401
|
+
|
|
402
|
+
# Temporarily remove auth from Swimlane session for auth request to avoid recursive loop during login request
|
|
403
|
+
self._swimlane._session.auth = None
|
|
404
|
+
resp = self._swimlane.request(
|
|
405
|
+
'post',
|
|
406
|
+
#'user/login',
|
|
407
|
+
'/tenant/api/users/login',
|
|
408
|
+
json={
|
|
409
|
+
'userName': self._username,
|
|
410
|
+
'password': self._password
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
self._swimlane._session.auth = self
|
|
414
|
+
|
|
415
|
+
# Get JWT from response content
|
|
416
|
+
json_content = resp.json()
|
|
417
|
+
token = json_content.pop('token', None)
|
|
418
|
+
|
|
419
|
+
# Grab token expiration
|
|
420
|
+
token_data = jwt.decode(token, algorithms=[''], options={'verify_signature': False})
|
|
421
|
+
token_expiration = pendulum.from_timestamp(token_data['exp'])
|
|
422
|
+
|
|
423
|
+
headers = {
|
|
424
|
+
'Authorization': 'Bearer {}'.format(token)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
# Create User instance for authenticating user from login response data
|
|
428
|
+
user = User(self._swimlane, _user_raw_from_login_content(json_content))
|
|
429
|
+
|
|
430
|
+
self._login_headers = headers
|
|
431
|
+
self.user = user
|
|
432
|
+
self._token_expiration = token_expiration
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _user_raw_from_login_content(login_content):
|
|
436
|
+
"""Returns a User instance with appropriate raw data parsed from login response content"""
|
|
437
|
+
matching_keys = [
|
|
438
|
+
'displayName',
|
|
439
|
+
'lastLogin',
|
|
440
|
+
'active',
|
|
441
|
+
'name',
|
|
442
|
+
'isMe',
|
|
443
|
+
'lastPasswordChangedDate',
|
|
444
|
+
'passwordResetRequired',
|
|
445
|
+
'groups',
|
|
446
|
+
'roles',
|
|
447
|
+
'email',
|
|
448
|
+
'isAdmin',
|
|
449
|
+
'createdDate',
|
|
450
|
+
'modifiedDate',
|
|
451
|
+
'createdByUser',
|
|
452
|
+
'modifiedByUser',
|
|
453
|
+
'userName',
|
|
454
|
+
'id',
|
|
455
|
+
'disabled'
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
raw_data = {
|
|
459
|
+
'$type': User._type,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for key in matching_keys:
|
|
463
|
+
if key in login_content:
|
|
464
|
+
raw_data[key] = login_content[key]
|
|
465
|
+
|
|
466
|
+
return raw_data
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Cursor(object):
|
|
5
|
+
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self._elements = []
|
|
8
|
+
|
|
9
|
+
def __len__(self):
|
|
10
|
+
return len(list(self._evaluate()))
|
|
11
|
+
|
|
12
|
+
def __iter__(self):
|
|
13
|
+
for element in self._evaluate():
|
|
14
|
+
yield element
|
|
15
|
+
|
|
16
|
+
def __getitem__(self, item):
|
|
17
|
+
return self._evaluate()[item]
|
|
18
|
+
|
|
19
|
+
def _evaluate(self):
|
|
20
|
+
"""Hook to allow lazy evaluation or retrieval of cursor's elements
|
|
21
|
+
|
|
22
|
+
Defaults to simply returning list of self._elements
|
|
23
|
+
"""
|
|
24
|
+
return self._elements
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PaginatedCursor(Cursor):
|
|
28
|
+
"""Handle paginated lists, exposes hooks to simplify retrieval and parsing of paginated data"""
|
|
29
|
+
|
|
30
|
+
default_limit = 0
|
|
31
|
+
default_page_size = 10
|
|
32
|
+
default_page_start = None
|
|
33
|
+
default_page_end = None
|
|
34
|
+
|
|
35
|
+
def __init__(self, limit=default_limit, page_size=default_page_size,
|
|
36
|
+
page_start=default_page_start, page_end=default_page_end):
|
|
37
|
+
super(PaginatedCursor, self).__init__()
|
|
38
|
+
|
|
39
|
+
self.__limit = limit
|
|
40
|
+
self.page_size = page_size
|
|
41
|
+
self.page_start = page_start
|
|
42
|
+
self.page_end = page_end
|
|
43
|
+
|
|
44
|
+
if self.__limit:
|
|
45
|
+
self.page_size = min(self.page_size, self.__limit)
|
|
46
|
+
|
|
47
|
+
if self.page_start and self.page_start <= 0:
|
|
48
|
+
raise ValueError('page_start should be greater than 0')
|
|
49
|
+
|
|
50
|
+
if self.page_end and self.page_end <= 0:
|
|
51
|
+
raise ValueError('page_end should be greater than 0')
|
|
52
|
+
|
|
53
|
+
if (self.page_start and self.page_end) and (self.page_start > self.page_end):
|
|
54
|
+
raise ValueError('page_end cannot be less than page_start')
|
|
55
|
+
|
|
56
|
+
if (self.page_start or self.page_end) and self.__limit != 0:
|
|
57
|
+
raise ValueError(' page_start or page_end param is applicable only when limit is 0')
|
|
58
|
+
|
|
59
|
+
def _evaluate(self):
|
|
60
|
+
"""Lazily retrieve and paginate report results and build Record instances from returned data"""
|
|
61
|
+
if self._elements:
|
|
62
|
+
for element in self._elements:
|
|
63
|
+
yield element
|
|
64
|
+
else:
|
|
65
|
+
# Determine pagination range based on parameters
|
|
66
|
+
if self.page_start and self.page_end:
|
|
67
|
+
page_range = range(self.page_start-1, self.page_end)
|
|
68
|
+
elif self.page_start:
|
|
69
|
+
page_range = itertools.count(self.page_start-1)
|
|
70
|
+
elif self.page_end:
|
|
71
|
+
page_range = range(0, self.page_end)
|
|
72
|
+
else:
|
|
73
|
+
page_range = itertools.count()
|
|
74
|
+
|
|
75
|
+
for page in page_range:
|
|
76
|
+
raw_elements = self._retrieve_raw_elements(page)
|
|
77
|
+
|
|
78
|
+
for raw_element in raw_elements:
|
|
79
|
+
element = self._parse_raw_element(raw_element)
|
|
80
|
+
self._elements.append(element)
|
|
81
|
+
yield element
|
|
82
|
+
|
|
83
|
+
if self.__limit and len(self._elements) >= self.__limit:
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
# Break conditions for ending pagination
|
|
87
|
+
if any([
|
|
88
|
+
len(raw_elements) < self.page_size,
|
|
89
|
+
self.__limit and len(self._elements) >= self.__limit,
|
|
90
|
+
self.page_size == 0
|
|
91
|
+
]):
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
def _retrieve_raw_elements(self, page):
|
|
95
|
+
"""Send request and return response for single page of data"""
|
|
96
|
+
raise NotImplementedError
|
|
97
|
+
|
|
98
|
+
def _parse_raw_element(self, raw_element):
|
|
99
|
+
"""Hook to override parsing individual raw elements just before yielding"""
|
|
100
|
+
return raw_element
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Abstractions for Swimlane app field types to simplify getting/setting values on records"""
|
|
2
|
+
|
|
3
|
+
from six import string_types as _string_types
|
|
4
|
+
|
|
5
|
+
from swimlane.core.fields.base import Field
|
|
6
|
+
from swimlane.utils import (
|
|
7
|
+
get_recursive_subclasses as _get_recursive_subclasses,
|
|
8
|
+
import_submodules as _import_submodules
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
_import_submodules(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build_field_type_map(base_class):
|
|
15
|
+
"""Create mapping from all $type values to their respective Field classes"""
|
|
16
|
+
mapping = {}
|
|
17
|
+
|
|
18
|
+
for cls in _get_recursive_subclasses(base_class):
|
|
19
|
+
if cls.field_type:
|
|
20
|
+
if isinstance(cls.field_type, tuple):
|
|
21
|
+
for field_type in cls.field_type:
|
|
22
|
+
mapping[field_type] = cls
|
|
23
|
+
elif isinstance(cls.field_type, _string_types):
|
|
24
|
+
mapping[cls.field_type] = cls
|
|
25
|
+
else:
|
|
26
|
+
raise ValueError('Field type must be str or tuple, cannot understand type "{}" on class "{}"'.format(
|
|
27
|
+
type(cls.field_type),
|
|
28
|
+
cls
|
|
29
|
+
))
|
|
30
|
+
|
|
31
|
+
return mapping
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_FIELD_TYPE_MAP = _build_field_type_map(Field)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_field_class(field_definition):
|
|
38
|
+
"""Return field class most fitting of provided Swimlane field definition"""
|
|
39
|
+
try:
|
|
40
|
+
return _FIELD_TYPE_MAP[field_definition['$type']]
|
|
41
|
+
except KeyError as error:
|
|
42
|
+
error.message = 'No field available to handle Swimlane $type "{}"'.format(field_definition)
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ['resolve_field_class'] + [f.__class__.__name__ for f in _FIELD_TYPE_MAP.values()]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import mimetypes
|
|
3
|
+
|
|
4
|
+
from swimlane.core.fields.base import MultiSelectField, FieldCursor
|
|
5
|
+
from swimlane.core.resources.attachment import Attachment
|
|
6
|
+
from swimlane.utils.str_validator import validate_str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AttachmentCursor(FieldCursor):
|
|
10
|
+
"""Allows creation and iteration of attachments"""
|
|
11
|
+
|
|
12
|
+
def add(self, filename, stream, content_type=None):
|
|
13
|
+
"""Upload a new attachment, and add it to current fields raw data to be persisted on save
|
|
14
|
+
|
|
15
|
+
Can optionally manually set the content_type, will be guessed by provided filename extension and default to
|
|
16
|
+
application/octet-stream if it cannot be guessed
|
|
17
|
+
"""
|
|
18
|
+
validate_str(filename, 'filename')
|
|
19
|
+
self.validate_stream(stream, 'stream')
|
|
20
|
+
|
|
21
|
+
# Guess file Content-Type or default
|
|
22
|
+
content_type = content_type or mimetypes.guess_type(
|
|
23
|
+
filename)[0] or 'application/octet-stream'
|
|
24
|
+
response = self._record._swimlane.request(
|
|
25
|
+
'post',
|
|
26
|
+
'attachment/{appId}/{fieldId}'.format(
|
|
27
|
+
appId=self._record.app.id, fieldId=self._field.id),
|
|
28
|
+
files={
|
|
29
|
+
'file': (filename, stream, content_type)
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Returns raw attachment data as list with single element
|
|
34
|
+
raw_attachment_data = response.json()[0]
|
|
35
|
+
|
|
36
|
+
attachment = Attachment(self._record._swimlane, raw_attachment_data, self._record.id, self._field.id)
|
|
37
|
+
self._elements.append(attachment)
|
|
38
|
+
|
|
39
|
+
self._sync_field()
|
|
40
|
+
|
|
41
|
+
return attachment
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_stream(self, stream, key):
|
|
45
|
+
if not isinstance(stream, io.IOBase):
|
|
46
|
+
raise ValueError('{} must be a stream value.'.format(key))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AttachmentsField(MultiSelectField):
|
|
50
|
+
|
|
51
|
+
field_type = (
|
|
52
|
+
'Core.Models.Fields.AttachmentField, Core',
|
|
53
|
+
'Core.Models.Fields.Attachment.AttachmentField, Core'
|
|
54
|
+
)
|
|
55
|
+
cursor_class = AttachmentCursor
|
|
56
|
+
supported_types = [Attachment]
|
|
57
|
+
bulk_modify_support = False
|
|
58
|
+
|
|
59
|
+
def __init__(self, *args, **kwargs):
|
|
60
|
+
"""Override to force-set multiselect to always True"""
|
|
61
|
+
super(AttachmentsField, self).__init__(*args, **kwargs)
|
|
62
|
+
self.multiselect = True
|
|
63
|
+
|
|
64
|
+
def get_initial_elements(self):
|
|
65
|
+
raw_value = self.get_swimlane() or []
|
|
66
|
+
|
|
67
|
+
return [self.cast_to_python(raw) for raw in raw_value]
|
|
68
|
+
|
|
69
|
+
def get_batch_representation(self):
|
|
70
|
+
"""Return best batch process representation of field value"""
|
|
71
|
+
return self.get_swimlane()
|
|
72
|
+
|
|
73
|
+
def _set(self, value):
|
|
74
|
+
"""Override setter, allow clearing cursor"""
|
|
75
|
+
super(AttachmentsField, self)._set(value)
|
|
76
|
+
self._cursor = None
|
|
77
|
+
|
|
78
|
+
def cast_to_python(self, value):
|
|
79
|
+
return Attachment(self._swimlane, value, self.record.id, self.id)
|
|
80
|
+
|
|
81
|
+
def cast_to_swimlane(self, value):
|
|
82
|
+
return value._raw
|