-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathlime.py
326 lines (286 loc) · 11.5 KB
/
lime.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
import base64
import json
import traceback
from contextlib import contextmanager
from pprint import pprint
import pendulum
from loguru import logger
from tinyrpc import InvalidReplyError
from tinyrpc.client import RPCClient, RPCProxy
from tinyrpc.protocols.jsonrpc import (
JSONRPCSuccessResponse,
JSONRPCErrorResponse,
JSONRPCProtocol,
)
from tinyrpc.transports.http import HttpPostClientTransport
class LimeAPI:
"""
LimeSurvey API Client for Remote API 2.
Aims to simplify and automate the necessary task of moving data out of
LimeSurvey without having to pull it directly from the site. Most of the
communication between the remote LimeSurvey instance and the API follows
the JSON-RPC 2 protocol.
request and response validation to help keep communication more consistent.
"""
# The following defaults are defined in a configuration file. See `settings.py` for
# more details.
_default_headers = {"content-type": "application/json", "connection": "Keep-Alive"}
# There are still a few pain points in dealing with the JSON-RPC protocol
_rpc_protocol_patched = False
def __init__(self, url, username, password, headers=None):
"""
LimeSurvey API Client for Remote API 2.
Aims to simplify and automate the necessary task of moving data out of
LimeSurvey in one way or another.
:param url: Fully-qualified LimeSurvey server URL containing protocol,
hostname, port, and the endpoint for the Remote Control 2 API.
:param username: LimeSurvey account username
:param password: LimeSurvey account password
:param headers: Headers to include when making requests. At a minimum, each
request should be fitted with an application/JSON content-type
declaration
"""
logger.info("Instantiating LimeAPI client")
self.url = url
self.username = username
self.password = password
self.headers = self._default_headers if headers is None else headers
self.session_key = None
self.rpc_client = None
self.rpc_proxy = None
self._authenticated = False
self._validate_settings()
@property
def remote_api_url(self):
return "/".join([self.url.rstrip("/"), "/index.php/admin/remotecontrol"])
def authenticate(self):
"""
Performs authentication actions
Patches `tinyrpc` to remove minor incompatibilities due to JSON-RPC
protocol version differences. This is performed once.
Initializes the RPCClient and RPCProxy instances that
are used to make the requests to Lime.
:return: None
"""
logger.info("Authenticating LimeAPI client")
if not LimeAPI._rpc_protocol_patched:
LimeAPI.patch_json_rpc_protocol()
LimeAPI._rpc_protocol_patched = True
self.rpc_client = RPCClient(
JSONRPCProtocol(),
HttpPostClientTransport(endpoint=self.remote_api_url, headers=self.headers),
)
self.rpc_proxy = self.rpc_client.get_proxy()
self.session_key = self.rpc_proxy.get_session_key(
username=self.username, password=self.password
)
if not self._validate_session_key():
raise Exception(
f"Failed to validate session key: url={self.url} "
f"session_key={self.session_key}"
)
self._authenticated = True
logger.info(f"Acquired session key: {self.session_key}")
def list_surveys(self, username=None):
"""
List the surveys belonging to a user
If user is admin, he can get surveys of every user (parameter sUser)
or all surveys (sUser=null). Otherwise, only the surveys belonging to the
user making the request will be shown.
Returns a JSON array of surveys containing the following keys:
sid startdate expires active surveyls_title
:param username: (optional) Include if you want to limit the scope of the
list operation or are only interested in the surveys that
belong to you.
:return: array of survey dict items
"""
with self.request_ctx("list_surveys"):
result = self.rpc_proxy.list_surveys(
sSessionKey=self.session_key, sUser=username or self.username
)
return result
def list_questions(
self, survey_id: int, group_id: int = None, language: str = None
):
"""
Return the ids and info of (sub-)questions of a survey/group.
:param survey_id: the survey ID
:param group_id: (optional) A group ID that can be used for filtering results
:param language:
:return: list of questions
"""
with self.request_ctx("list_questions"):
result = self.rpc_proxy.list_questions(
sSessionKey=self.session_key,
iSurveyID=survey_id,
iGroupId=group_id,
sLanguage=language,
)
return result
def get_language_properties(self, survey_id: int):
"""
Gets language properties
:param survey_id:
:return:
"""
with self.request_ctx("get_language_properties"):
result = self.rpc_proxy.get_language_properties(
sSessionKey=self.session_key,
iSurveyID=survey_id,
aSurveyLocaleSettings=None,
sLang=None,
)
return result
def get_survey_properties(self, survey_id: int):
"""
Retrieves survey properties
Additional properties (including the survey title)
must be retrieved from the 'get_language_properties' endpoint
:param survey_id: the survey ID to retrieve
:return: list
"""
with self.request_ctx("get_survey_properties"):
result = self.rpc_proxy.get_survey_properties(
sSessionKey=self.session_key,
iSurveyID=survey_id,
aSurveyLocaleSettings=None,
sLang=None,
)
return result
@contextmanager
def request_ctx(self, endpoint):
"""
A common helper context that is used for all of the endpoints
It provides authentication, error handling, logging, and
stat collection
:param endpoint:
:param ctx:
:return:
"""
logger.info(f"Sending LimeAPI RPC Request for endpoint [{endpoint}]")
t0 = pendulum.now()
try:
self._validate_auth()
yield
duration = (pendulum.now() - t0).total_seconds() * 1000
logger.info(f"Endpoint [{endpoint}]: Request completed in {duration} ms")
error = None
except Exception as e:
error = e
traceback.print_exc()
if error:
raise error
def export_responses(
self,
survey_id: int,
language_code: str = None,
completion_status: str = "all",
heading_type: str = "code",
response_type: str = "long",
from_response_id: int = None,
to_response_id: int = None,
fields=None,
):
with self.request_ctx("get_survey_properties"):
_completion_statuses = ["complete", "incomplete", "all"]
_heading_types = ["code", "full", "abbreviated"]
_response_types = ["short", "long"]
result_b64 = self.rpc_proxy.export_responses(
sSessionKey=self.session_key,
iSurveyID=survey_id,
sDocumentType="json",
sLanguageCode=language_code,
sCompletionStatus=completion_status,
sHeadingType=heading_type,
sResponseType=response_type,
iFromResponseID=from_response_id,
iToResponseID=to_response_id,
aFields=fields,
)
result_utf8 = base64.b64decode(result_b64).decode("utf-8")
result_json = json.loads(result_utf8)
result_json = result_json["responses"]
rows = []
for id_survey_map in result_json:
for survey in id_survey_map.values():
rows.append(survey)
return rows
@staticmethod
def patch_json_rpc_protocol():
def parse_reply_patched(self, data):
"""Deserializes and validates a response.
Called by the client to reconstruct the serialized :py:class:`JSONRPCResponse`.
:param bytes data: The data stream received by the transport layer containing the
serialized request.
:return: A reconstructed response.
:rtype: :py:class:`JSONRPCSuccessResponse` or :py:class:`JSONRPCErrorResponse`
:raises InvalidReplyError: if the response is not valid JSON or does not conform
to the standard.
"""
if isinstance(data, bytes):
data = data.decode()
try:
print(data)
rep = json.loads(data)
except Exception as e:
traceback.print_exc()
raise InvalidReplyError(e)
for k in rep.keys():
if not k in self._ALLOWED_REPLY_KEYS:
raise InvalidReplyError("Key not allowed: %s" % k)
if "id" not in rep:
raise InvalidReplyError("Missing id in response")
if "error" in rep and rep["error"] is not None:
pprint(rep)
response = JSONRPCErrorResponse()
error = rep["error"]
if isinstance(error, str):
response.error = error
response.code = -1
response._jsonrpc_error_code = -1
else:
response.error = error["message"]
response._jsonrpc_error_code = error["code"]
if "data" in error:
response.data = error["data"]
else:
response = JSONRPCSuccessResponse()
response.result = rep.get("result", None)
response.unique_id = rep["id"]
return response
logger.info("Patching JSONRPCProtocol `parse_reply` method")
JSONRPCProtocol.parse_reply = parse_reply_patched
def _validate_settings(self):
"""
Makes sure that the we instantiated properly
:return:
"""
logger.info("Validating LimeAPI settings")
if None in [self.url, self.username, self.password]:
raise EnvironmentError()
def _validate_rpc_resources(self):
if not isinstance(self.rpc_client, RPCClient):
return False
if not isinstance(self.rpc_proxy, RPCProxy):
return False
return True
def _validate_session_key(self):
if self.session_key is None:
return False
if not isinstance(self.session_key, str):
return False
if type(self.session_key) is str and len(self.session_key) == 0:
return False
return True
def _validate_auth(self):
"""
Checks for authentication issues
:return: None
"""
# Checking that we ran auth initialization
if not self._authenticated:
self.authenticate()
elif not self._validate_session_key():
self.authenticate()
elif not self._validate_rpc_resources():
self.authenticate()