qmenta-client 1.1.dev1245__py3-none-any.whl → 1.1.dev1295__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.
- qmenta/client/Project.py +577 -393
- {qmenta_client-1.1.dev1245.dist-info → qmenta_client-1.1.dev1295.dist-info}/METADATA +1 -1
- {qmenta_client-1.1.dev1245.dist-info → qmenta_client-1.1.dev1295.dist-info}/RECORD +4 -4
- {qmenta_client-1.1.dev1245.dist-info → qmenta_client-1.1.dev1295.dist-info}/WHEEL +0 -0
qmenta/client/Project.py
CHANGED
|
@@ -11,7 +11,6 @@ from enum import Enum
|
|
|
11
11
|
from qmenta.client import Account
|
|
12
12
|
from qmenta.core import errors
|
|
13
13
|
from qmenta.core import platform
|
|
14
|
-
from .Subject import Subject
|
|
15
14
|
|
|
16
15
|
if sys.version_info[0] == 3:
|
|
17
16
|
# Note: this branch & variable is only needed for python 2/3 compatibility
|
|
@@ -23,7 +22,8 @@ logger_name = "qmenta.client"
|
|
|
23
22
|
def show_progress(done, total, finish=False):
|
|
24
23
|
bytes_in_mb = 1024 * 1024
|
|
25
24
|
progress_message = "\r[{:.2f} %] Uploaded {:.2f} of {:.2f} Mb".format(
|
|
26
|
-
done / float(total) * 100, done / bytes_in_mb, total / bytes_in_mb
|
|
25
|
+
done / float(total) * 100, done / bytes_in_mb, total / bytes_in_mb
|
|
26
|
+
)
|
|
27
27
|
sys.stdout.write(progress_message)
|
|
28
28
|
sys.stdout.flush()
|
|
29
29
|
if not finish:
|
|
@@ -72,6 +72,7 @@ class QCStatus(Enum):
|
|
|
72
72
|
Enum with the following options:
|
|
73
73
|
FAIL, PASS
|
|
74
74
|
"""
|
|
75
|
+
|
|
75
76
|
PASS = "pass"
|
|
76
77
|
FAIL = "fail"
|
|
77
78
|
|
|
@@ -95,15 +96,11 @@ class Project:
|
|
|
95
96
|
# project id (int)
|
|
96
97
|
if isinstance(project_id, str):
|
|
97
98
|
project_name = project_id
|
|
98
|
-
project_id = next(iter(filter(
|
|
99
|
-
lambda proj: proj["name"] == project_id, account.projects)
|
|
100
|
-
))["id"]
|
|
99
|
+
project_id = next(iter(filter(lambda proj: proj["name"] == project_id, account.projects)))["id"]
|
|
101
100
|
else:
|
|
102
101
|
if isinstance(project_id, float):
|
|
103
102
|
project_id = int(project_id)
|
|
104
|
-
project_name = next(iter(filter(
|
|
105
|
-
lambda proj: proj["id"] == project_id, account.projects)
|
|
106
|
-
))["name"]
|
|
103
|
+
project_name = next(iter(filter(lambda proj: proj["id"] == project_id, account.projects)))["name"]
|
|
107
104
|
|
|
108
105
|
self._account = account
|
|
109
106
|
self._project_id = project_id
|
|
@@ -134,11 +131,11 @@ class Project:
|
|
|
134
131
|
"""
|
|
135
132
|
logger = logging.getLogger(logger_name)
|
|
136
133
|
try:
|
|
137
|
-
platform.parse_response(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
134
|
+
platform.parse_response(
|
|
135
|
+
platform.post(
|
|
136
|
+
self._account.auth, "projectset_manager/activate_project", data={"project_id": int(project_id)}
|
|
137
|
+
)
|
|
138
|
+
)
|
|
142
139
|
except errors.PlatformError:
|
|
143
140
|
logger.error("Unable to activate the project.")
|
|
144
141
|
return False
|
|
@@ -161,45 +158,139 @@ class Project:
|
|
|
161
158
|
dict
|
|
162
159
|
A list of dictionary of {"metadata_name": "metadata_value"}
|
|
163
160
|
"""
|
|
164
|
-
return self.get_subjects_metadata(
|
|
161
|
+
return self.get_subjects_metadata()
|
|
165
162
|
|
|
166
|
-
def get_subjects_metadata(self,
|
|
163
|
+
def get_subjects_metadata(self, search_criteria={}, items=(0, 9999)):
|
|
167
164
|
"""
|
|
168
|
-
List all
|
|
165
|
+
List all subjects data from the selected project that meet the defined
|
|
166
|
+
search criteria.
|
|
167
|
+
|
|
169
168
|
Parameters
|
|
170
169
|
----------
|
|
171
|
-
cache: bool
|
|
172
|
-
Whether to use the cached metadata or not
|
|
173
|
-
|
|
174
170
|
search_criteria: dict
|
|
175
171
|
Each element is a string and is built using the formatting
|
|
176
|
-
|
|
172
|
+
"type;value", or "type;operation|value"
|
|
173
|
+
|
|
174
|
+
Complete search_criteria Dictionary Explanation:
|
|
175
|
+
|
|
176
|
+
search_criteria = {
|
|
177
|
+
"pars_patient_secret_name": "string;SUBJECTID",
|
|
178
|
+
"pars_ssid": "integer;OPERATOR|SSID",
|
|
179
|
+
"pars_modalities": "string;MODALITY",
|
|
180
|
+
"pars_tags": "tags;TAGS",
|
|
181
|
+
"pars_age_at_scan": "integer;OPERATOR|AGE_AT_SCAN",
|
|
182
|
+
"pars_[dicom]_KEY": "KEYTYPE;KEYVALUE",
|
|
183
|
+
"pars_PROJECTMETADATA": "METADATATYPE;METADATAVALUE",
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
Where:
|
|
187
|
+
"pars_patient_secret_name": Applies the search to the 'Subject ID'.
|
|
188
|
+
SUBJECTID is a comma separated list of strings.
|
|
189
|
+
"pars_ssid": Applies the search to the 'Session ID'.
|
|
190
|
+
SSID is an integer.
|
|
191
|
+
OPERATOR is the operator to apply. One of:
|
|
192
|
+
- Equal: eq
|
|
193
|
+
- Different Than: ne
|
|
194
|
+
- Greater Than: gt
|
|
195
|
+
- Greater/Equal To: gte
|
|
196
|
+
- Lower Than: lt
|
|
197
|
+
- Lower/Equal To: lte
|
|
198
|
+
|
|
199
|
+
"pars_modalities": Applies the search to the file 'Modalities'
|
|
200
|
+
available within each Subject ID.
|
|
201
|
+
MODALITY is a comma separated list of string.
|
|
202
|
+
"pars_tags": Applies the search to the file 'Tags' available within
|
|
203
|
+
each Subject ID and to the subject-level 'Tags'.
|
|
204
|
+
TAGS is a comma separated list of strings.
|
|
205
|
+
"pars_age_at_scan": Applies the search to the 'age_at_scan' metadata
|
|
206
|
+
field.
|
|
207
|
+
AGE_AT_SCAN is an integer.
|
|
208
|
+
"pars_[dicom]_KEY": Applies the search to the metadata fields
|
|
209
|
+
available within each file. KEY must be one of the
|
|
210
|
+
metadata keys of the files. The full list of KEYS is shown above via
|
|
211
|
+
'file_m["metadata"]["info"].keys()'.
|
|
212
|
+
KEYTYPE is the type of the KEY. One of:
|
|
213
|
+
- integer
|
|
214
|
+
- string
|
|
215
|
+
- list
|
|
216
|
+
|
|
217
|
+
if 'integer' you must also include an OPERATOR
|
|
218
|
+
(i.e., "integer;OPERATOR|KEYVALUE").
|
|
219
|
+
KEYVALUE is the expected value of the KEY.
|
|
220
|
+
"pars_[dicom]_PROJECTMETADATA": Applies to the metadata defined
|
|
221
|
+
within the 'Metadata Manager' of the project.
|
|
222
|
+
PROJECTMETADATA is the ID of the metadata field.
|
|
223
|
+
METADATATYPE is the type of the metadata field. One of:
|
|
224
|
+
- string
|
|
225
|
+
- integer
|
|
226
|
+
- list
|
|
227
|
+
- decimal
|
|
228
|
+
- single_option
|
|
229
|
+
- multiple_option
|
|
230
|
+
|
|
231
|
+
if 'integer' or 'decimal' you must also include an OPERATOR
|
|
232
|
+
(i.e., "integer;OPERATOR|METADATAVALUE").
|
|
233
|
+
KEYVALUE is the expected value of the metadata.
|
|
234
|
+
|
|
235
|
+
1) Example:
|
|
236
|
+
search_criteria = {
|
|
237
|
+
"pars_patient_secret_name": "string;abide",
|
|
238
|
+
"pars_ssid": "integer;eq|2"
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
2) Example:
|
|
242
|
+
search_criteria = {
|
|
243
|
+
"pars_modalities": "string;T1",
|
|
244
|
+
"pars_tags": "tags;flair",
|
|
245
|
+
"pars_[dicom]_Manufacturer": "string;ge",
|
|
246
|
+
"pars_[dicom]_FlipAngle": "integer;gt|5",
|
|
247
|
+
}
|
|
177
248
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
249
|
+
Note the search criteria applies to all the files included within a
|
|
250
|
+
session. Hence, it does not imply that all the criteria conditions
|
|
251
|
+
are applied to the same files. In example 2) above, it means that
|
|
252
|
+
any Subject ID/Session ID that has a file classified with a 'T1'
|
|
253
|
+
modality, a file with a 'flair' tag, a file whose Manufacturer
|
|
254
|
+
contains 'ge', and a file whose FlipAngle is greater than '5º' will
|
|
255
|
+
be selected.
|
|
181
256
|
|
|
182
257
|
Returns
|
|
183
258
|
-------
|
|
184
259
|
dict
|
|
185
260
|
A list of dictionary of {"metadata_name": "metadata_value"}
|
|
261
|
+
|
|
186
262
|
"""
|
|
187
263
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
264
|
+
assert len(items) == 2, f"The number of elements in items " f"'{len(items)}' should be equal to two."
|
|
265
|
+
assert all([isinstance(item, int) for item in items]), f"All items elements '{items}' should be integers."
|
|
266
|
+
|
|
267
|
+
assert all([key[:5] == "pars_" for key in search_criteria.keys()]), (
|
|
268
|
+
f"All keys of the search_criteria dictionary " f"'{search_criteria.keys()}' must start with 'pars_'."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
operator_list = ["eq", "ne", "gt", "gte", "lt", "lte"]
|
|
272
|
+
for key, value in search_criteria.items():
|
|
273
|
+
if value.split(";")[0] in ["integer", "decimal"]:
|
|
274
|
+
assert value.split(";")[1].split("|")[0] in operator_list, (
|
|
275
|
+
f"Search criteria of type '{value.split(';')[0]}' must "
|
|
276
|
+
f"include an operator ({', '.join(operator_list)})."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
content = platform.parse_response(
|
|
280
|
+
platform.post(
|
|
281
|
+
self._account.auth,
|
|
282
|
+
"patient_manager/get_patient_list",
|
|
191
283
|
data=search_criteria,
|
|
192
|
-
headers={"X-Range": "items=0-
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
else:
|
|
196
|
-
content = self._subjects_metadata
|
|
284
|
+
headers={"X-Range": f"items={items[0]}-{items[1]}"},
|
|
285
|
+
)
|
|
286
|
+
)
|
|
197
287
|
return content
|
|
198
288
|
|
|
199
289
|
@property
|
|
200
290
|
def subjects(self):
|
|
201
291
|
"""
|
|
202
|
-
Return the list of subject names from the selected
|
|
292
|
+
Return the list of subject names (Subject ID) from the selected
|
|
293
|
+
project.
|
|
203
294
|
|
|
204
295
|
:return: a list of subject names
|
|
205
296
|
:rtype: List(Strings)
|
|
@@ -211,12 +302,13 @@ class Project:
|
|
|
211
302
|
|
|
212
303
|
def check_subject_name(self, subject_name):
|
|
213
304
|
"""
|
|
214
|
-
Check if a given subject name exists in the selected
|
|
305
|
+
Check if a given subject name (Subject ID) exists in the selected
|
|
306
|
+
project.
|
|
215
307
|
|
|
216
308
|
Parameters
|
|
217
309
|
----------
|
|
218
310
|
subject_name : str
|
|
219
|
-
|
|
311
|
+
Subject ID of the subject to check
|
|
220
312
|
|
|
221
313
|
Returns
|
|
222
314
|
-------
|
|
@@ -247,16 +339,13 @@ class Project:
|
|
|
247
339
|
"""
|
|
248
340
|
logger = logging.getLogger(logger_name)
|
|
249
341
|
try:
|
|
250
|
-
data = platform.parse_response(platform.post(
|
|
251
|
-
self._account.auth, "patient_manager/module_config"
|
|
252
|
-
))
|
|
342
|
+
data = platform.parse_response(platform.post(self._account.auth, "patient_manager/module_config"))
|
|
253
343
|
except errors.PlatformError:
|
|
254
344
|
logger.error("Could not retrieve metadata parameters.")
|
|
255
345
|
return None
|
|
256
346
|
return data["fields"]
|
|
257
347
|
|
|
258
|
-
def add_metadata_parameter(self, title, param_id=None,
|
|
259
|
-
param_type="string", visible=False):
|
|
348
|
+
def add_metadata_parameter(self, title, param_id=None, param_type="string", visible=False):
|
|
260
349
|
"""
|
|
261
350
|
Add a metadata parameter to the project.
|
|
262
351
|
|
|
@@ -282,18 +371,13 @@ class Project:
|
|
|
282
371
|
|
|
283
372
|
param_properties = [title, param_id, param_type, str(int(visible))]
|
|
284
373
|
|
|
285
|
-
post_data = {"add": "|".join(param_properties),
|
|
286
|
-
"edit": "",
|
|
287
|
-
"delete": ""
|
|
288
|
-
}
|
|
374
|
+
post_data = {"add": "|".join(param_properties), "edit": "", "delete": ""}
|
|
289
375
|
|
|
290
376
|
logger = logging.getLogger(logger_name)
|
|
291
377
|
try:
|
|
292
|
-
answer = platform.parse_response(
|
|
293
|
-
self._account.auth,
|
|
294
|
-
|
|
295
|
-
data=post_data
|
|
296
|
-
))
|
|
378
|
+
answer = platform.parse_response(
|
|
379
|
+
platform.post(self._account.auth, "patient_manager/save_metadata_changes", data=post_data)
|
|
380
|
+
)
|
|
297
381
|
except errors.PlatformError:
|
|
298
382
|
answer = {}
|
|
299
383
|
|
|
@@ -310,20 +394,17 @@ class Project:
|
|
|
310
394
|
elif isinstance(analysis_name_or_id, str):
|
|
311
395
|
search_tag = "p_n"
|
|
312
396
|
else:
|
|
313
|
-
raise Exception("The analysis identifier must be its name or an "
|
|
314
|
-
"integer")
|
|
397
|
+
raise Exception("The analysis identifier must be its name or an " "integer")
|
|
315
398
|
|
|
316
399
|
search_condition = {
|
|
317
400
|
search_tag: analysis_name_or_id,
|
|
318
401
|
}
|
|
319
|
-
response = platform.parse_response(
|
|
320
|
-
self._account.auth, "analysis_manager/get_analysis_list",
|
|
321
|
-
|
|
322
|
-
))
|
|
402
|
+
response = platform.parse_response(
|
|
403
|
+
platform.post(self._account.auth, "analysis_manager/get_analysis_list", data=search_condition)
|
|
404
|
+
)
|
|
323
405
|
|
|
324
406
|
if len(response) > 1:
|
|
325
|
-
raise Exception(f"multiple analyses with name "
|
|
326
|
-
f"{analysis_name_or_id} found")
|
|
407
|
+
raise Exception(f"multiple analyses with name " f"{analysis_name_or_id} found")
|
|
327
408
|
elif len(response) == 1:
|
|
328
409
|
return response[0]
|
|
329
410
|
else:
|
|
@@ -363,10 +444,8 @@ class Project:
|
|
|
363
444
|
dict
|
|
364
445
|
List of analysis, each a dictionary
|
|
365
446
|
"""
|
|
366
|
-
assert len(items) == 2, f"The number of elements in items "
|
|
367
|
-
|
|
368
|
-
assert all([isinstance(item, int) for item in items]), \
|
|
369
|
-
f"All items elements '{items}' should be integers."
|
|
447
|
+
assert len(items) == 2, f"The number of elements in items " f"'{len(items)}' should be equal to two."
|
|
448
|
+
assert all([isinstance(item, int) for item in items]), f"All items elements '{items}' should be integers."
|
|
370
449
|
search_keys = {
|
|
371
450
|
"p_n": str,
|
|
372
451
|
"type": str,
|
|
@@ -382,70 +461,90 @@ class Project:
|
|
|
382
461
|
}
|
|
383
462
|
for key in search_condition.keys():
|
|
384
463
|
if key not in search_keys.keys():
|
|
385
|
-
raise Exception(
|
|
386
|
-
(
|
|
387
|
-
f"This key '{key}' is not accepted by this"
|
|
388
|
-
"search condition"
|
|
389
|
-
)
|
|
390
|
-
)
|
|
464
|
+
raise Exception((f"This key '{key}' is not accepted by this" "search condition"))
|
|
391
465
|
if not isinstance(search_condition[key], search_keys[key]):
|
|
392
|
-
raise Exception(
|
|
393
|
-
(
|
|
394
|
-
f"The key {key} in the search condition"
|
|
395
|
-
f"is not type {search_keys[key]}"
|
|
396
|
-
)
|
|
397
|
-
)
|
|
466
|
+
raise Exception((f"The key {key} in the search condition" f"is not type {search_keys[key]}"))
|
|
398
467
|
req_headers = {"X-Range": f"items={items[0]}-{items[1] - 1}"}
|
|
399
|
-
return platform.parse_response(
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def get_container(self, subject_name):
|
|
407
|
-
search_condition = {
|
|
408
|
-
"s_n": subject_name,
|
|
409
|
-
}
|
|
410
|
-
response = self.list_input_containers(
|
|
411
|
-
search_condition=search_condition
|
|
468
|
+
return platform.parse_response(
|
|
469
|
+
platform.post(
|
|
470
|
+
auth=self._account.auth,
|
|
471
|
+
endpoint="analysis_manager/get_analysis_list",
|
|
472
|
+
headers=req_headers,
|
|
473
|
+
data=search_condition,
|
|
474
|
+
)
|
|
412
475
|
)
|
|
413
476
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
477
|
+
def get_subject_container_id(self, subject_name, ssid):
|
|
478
|
+
"""
|
|
479
|
+
Given a Subject ID and Session ID, return its Container ID.
|
|
480
|
+
|
|
481
|
+
Parameters
|
|
482
|
+
----------
|
|
483
|
+
subject_name : str
|
|
484
|
+
Subject ID of the subject in the project.
|
|
485
|
+
ssid : str
|
|
486
|
+
Session ID of the subject in the project.
|
|
487
|
+
|
|
488
|
+
Returns
|
|
489
|
+
-------
|
|
490
|
+
int or bool
|
|
491
|
+
The Container ID of the subject in the project, or False if
|
|
492
|
+
the subject is not found.
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
search_criteria = {"s_n": subject_name, "ssid": ssid}
|
|
496
|
+
response = self.list_input_containers(search_criteria=search_criteria)
|
|
421
497
|
|
|
422
|
-
|
|
498
|
+
for subject in response:
|
|
499
|
+
if subject["patient_secret_name"] == subject_name and subject["ssid"] == ssid:
|
|
500
|
+
return subject["container_id"]
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
def list_input_containers(self, search_criteria={}, items=(0, 9999)):
|
|
423
504
|
"""
|
|
424
|
-
|
|
505
|
+
Retrieve the list of input containers available to the user under a
|
|
506
|
+
certain search criteria.
|
|
425
507
|
|
|
426
508
|
Parameters
|
|
427
509
|
----------
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
510
|
+
search_criteria : dict
|
|
511
|
+
Each element is a string and is built using the formatting
|
|
512
|
+
"type;value".
|
|
513
|
+
|
|
514
|
+
List of possible keys:
|
|
515
|
+
d_n: container_name # TODO: WHAT IS THIS???
|
|
516
|
+
s_n: subject_id
|
|
517
|
+
Subject ID of the subject in the platform.
|
|
518
|
+
ssid: session_id
|
|
519
|
+
Session ID of the subejct in the platform.
|
|
520
|
+
from_d: from date
|
|
521
|
+
Starting date in which perform the search. Format: DD.MM.YYYY
|
|
522
|
+
to_d: to date
|
|
523
|
+
End date in which perform the search. Format: DD.MM.YYYY
|
|
524
|
+
sets: data sets (modalities) # TODO: WHAT IS THIS???
|
|
525
|
+
|
|
526
|
+
items: Tuple(int, int)
|
|
527
|
+
Starting and ending element of the search.
|
|
436
528
|
|
|
437
529
|
Returns
|
|
438
530
|
-------
|
|
439
531
|
dict
|
|
440
|
-
|
|
441
|
-
|
|
532
|
+
List of containers, each a dictionary containing the following
|
|
533
|
+
information:
|
|
534
|
+
{"container_name", "container_id", "patient_secret_name", "ssid"}
|
|
442
535
|
"""
|
|
443
536
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
537
|
+
assert len(items) == 2, f"The number of elements in items " f"'{len(items)}' should be equal to two."
|
|
538
|
+
assert all([isinstance(item, int) for item in items]), f"All items elements '{items}' should be integers."
|
|
539
|
+
|
|
540
|
+
response = platform.parse_response(
|
|
541
|
+
platform.post(
|
|
542
|
+
self._account.auth,
|
|
543
|
+
"file_manager/get_container_list",
|
|
544
|
+
data=search_criteria,
|
|
545
|
+
headers={"X-Range": f"items={items[0]}-{items[1]}"},
|
|
546
|
+
)
|
|
547
|
+
)
|
|
449
548
|
containers = [
|
|
450
549
|
{
|
|
451
550
|
"patient_secret_name": container_item["patient_secret_name"],
|
|
@@ -473,8 +572,7 @@ class Project:
|
|
|
473
572
|
{"name": "container-name", "id": "container_id"}
|
|
474
573
|
"""
|
|
475
574
|
analysis = self.list_analysis(limit)
|
|
476
|
-
return [{"name": a["name"],
|
|
477
|
-
"id": a["out_container_id"]} for a in analysis]
|
|
575
|
+
return [{"name": a["name"], "id": a["out_container_id"]} for a in analysis]
|
|
478
576
|
|
|
479
577
|
def list_container_files(self, container_id):
|
|
480
578
|
"""
|
|
@@ -491,10 +589,11 @@ class Project:
|
|
|
491
589
|
List of file names (strings)
|
|
492
590
|
"""
|
|
493
591
|
try:
|
|
494
|
-
content = platform.parse_response(
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
592
|
+
content = platform.parse_response(
|
|
593
|
+
platform.post(
|
|
594
|
+
self._account.auth, "file_manager/get_container_files", data={"container_id": container_id}
|
|
595
|
+
)
|
|
596
|
+
)
|
|
498
597
|
except errors.PlatformError as e:
|
|
499
598
|
logging.getLogger(logger_name).error(e)
|
|
500
599
|
return False
|
|
@@ -521,10 +620,11 @@ class Project:
|
|
|
521
620
|
"""
|
|
522
621
|
|
|
523
622
|
try:
|
|
524
|
-
data = platform.parse_response(
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
623
|
+
data = platform.parse_response(
|
|
624
|
+
platform.post(
|
|
625
|
+
self._account.auth, "file_manager/get_container_files", data={"container_id": container_id}
|
|
626
|
+
)
|
|
627
|
+
)
|
|
528
628
|
except errors.PlatformError as e:
|
|
529
629
|
logging.getLogger(logger_name).error(e)
|
|
530
630
|
return False
|
|
@@ -545,12 +645,15 @@ class Project:
|
|
|
545
645
|
Returns
|
|
546
646
|
-------
|
|
547
647
|
dict
|
|
548
|
-
Dictionary with the metadata.
|
|
648
|
+
Dictionary with the metadata. False otherwise.
|
|
549
649
|
"""
|
|
550
650
|
all_metadata = self.list_container_files_metadata(container_id)
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
651
|
+
if all_metadata:
|
|
652
|
+
for file_meta in all_metadata:
|
|
653
|
+
if file_meta["name"] == filename:
|
|
654
|
+
return file_meta
|
|
655
|
+
else:
|
|
656
|
+
return False
|
|
554
657
|
|
|
555
658
|
def change_file_metadata(self, container_id, filename, modality, tags):
|
|
556
659
|
"""
|
|
@@ -570,18 +673,15 @@ class Project:
|
|
|
570
673
|
"""
|
|
571
674
|
|
|
572
675
|
tags_str = "" if tags is None else ";".join(tags)
|
|
573
|
-
platform.parse_response(
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
"
|
|
577
|
-
"filename": filename,
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
}
|
|
581
|
-
))
|
|
676
|
+
platform.parse_response(
|
|
677
|
+
platform.post(
|
|
678
|
+
self._account.auth,
|
|
679
|
+
"file_manager/edit_file",
|
|
680
|
+
data={"container_id": container_id, "filename": filename, "tags": tags_str, "modality": modality},
|
|
681
|
+
)
|
|
682
|
+
)
|
|
582
683
|
|
|
583
|
-
def download_file(self, container_id, file_name, local_filename=False,
|
|
584
|
-
overwrite=False):
|
|
684
|
+
def download_file(self, container_id, file_name, local_filename=False, overwrite=False):
|
|
585
685
|
"""
|
|
586
686
|
Download a single file from a specific container.
|
|
587
687
|
|
|
@@ -598,8 +698,7 @@ class Project:
|
|
|
598
698
|
"""
|
|
599
699
|
logger = logging.getLogger(logger_name)
|
|
600
700
|
if file_name not in self.list_container_files(container_id):
|
|
601
|
-
msg =
|
|
602
|
-
f"{container_id}")
|
|
701
|
+
msg = f'File "{file_name}" does not exist in container ' f"{container_id}"
|
|
603
702
|
logger.error(msg)
|
|
604
703
|
return False
|
|
605
704
|
|
|
@@ -612,22 +711,18 @@ class Project:
|
|
|
612
711
|
|
|
613
712
|
params = {"container_id": container_id, "files": file_name}
|
|
614
713
|
|
|
615
|
-
with platform.post(
|
|
616
|
-
|
|
617
|
-
|
|
714
|
+
with platform.post(
|
|
715
|
+
self._account.auth, "file_manager/download_file", data=params, stream=True
|
|
716
|
+
) as response, open(local_filename, "wb") as f:
|
|
618
717
|
|
|
619
|
-
for chunk in response.iter_content(chunk_size=2
|
|
718
|
+
for chunk in response.iter_content(chunk_size=2**9 * 1024):
|
|
620
719
|
f.write(chunk)
|
|
621
720
|
f.flush()
|
|
622
721
|
|
|
623
|
-
logger.info(
|
|
624
|
-
f"File {file_name} from container {container_id} saved to"
|
|
625
|
-
f" {local_filename}"
|
|
626
|
-
)
|
|
722
|
+
logger.info(f"File {file_name} from container {container_id} saved to" f" {local_filename}")
|
|
627
723
|
return True
|
|
628
724
|
|
|
629
|
-
def download_files(self, container_id, filenames, zip_name="files.zip",
|
|
630
|
-
overwrite=False):
|
|
725
|
+
def download_files(self, container_id, filenames, zip_name="files.zip", overwrite=False):
|
|
631
726
|
"""
|
|
632
727
|
Download a set of files from a given container.
|
|
633
728
|
|
|
@@ -643,46 +738,43 @@ class Project:
|
|
|
643
738
|
Name of the zip where the downloaded files are stored.
|
|
644
739
|
"""
|
|
645
740
|
logger = logging.getLogger(logger_name)
|
|
646
|
-
files_not_in_container = list(
|
|
647
|
-
filter(lambda f: f not in self.list_container_files(container_id),
|
|
648
|
-
filenames)
|
|
649
|
-
)
|
|
741
|
+
files_not_in_container = list(filter(lambda f: f not in self.list_container_files(container_id), filenames))
|
|
650
742
|
|
|
651
743
|
if files_not_in_container:
|
|
652
|
-
msg = (
|
|
653
|
-
|
|
744
|
+
msg = (
|
|
745
|
+
f"The following files are missing in container " f"{container_id}: {', '.join(files_not_in_container)}"
|
|
746
|
+
)
|
|
654
747
|
logger.error(msg)
|
|
655
748
|
return False
|
|
656
749
|
|
|
657
750
|
if os.path.exists(zip_name) and not overwrite:
|
|
658
|
-
msg = f
|
|
751
|
+
msg = f'File "{zip_name}" already exists'
|
|
659
752
|
logger.error(msg)
|
|
660
753
|
return False
|
|
661
754
|
|
|
662
755
|
params = {"container_id": container_id, "files": ";".join(filenames)}
|
|
663
|
-
with platform.post(
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
open(zip_name, "wb") as f:
|
|
756
|
+
with platform.post(
|
|
757
|
+
self._account.auth, "file_manager/download_file", data=params, stream=True
|
|
758
|
+
) as response, open(zip_name, "wb") as f:
|
|
667
759
|
|
|
668
|
-
for chunk in response.iter_content(chunk_size=2
|
|
760
|
+
for chunk in response.iter_content(chunk_size=2**9 * 1024):
|
|
669
761
|
f.write(chunk)
|
|
670
762
|
f.flush()
|
|
671
763
|
|
|
672
|
-
logger.info("Files from container {} saved to {}".format(
|
|
673
|
-
container_id, zip_name))
|
|
764
|
+
logger.info("Files from container {} saved to {}".format(container_id, zip_name))
|
|
674
765
|
return True
|
|
675
766
|
|
|
676
|
-
def get_subject_id(self, subject_name,
|
|
767
|
+
def get_subject_id(self, subject_name, ssid):
|
|
677
768
|
"""
|
|
678
|
-
Given a
|
|
769
|
+
Given a Subject ID and Session ID, return its Patient ID in the
|
|
770
|
+
project.
|
|
679
771
|
|
|
680
772
|
Parameters
|
|
681
773
|
----------
|
|
682
774
|
subject_name : str
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
775
|
+
Subject ID of the subject in the project.
|
|
776
|
+
ssid : str
|
|
777
|
+
Session ID of the subject in the project.
|
|
686
778
|
|
|
687
779
|
Returns
|
|
688
780
|
-------
|
|
@@ -691,37 +783,11 @@ class Project:
|
|
|
691
783
|
the subject is not found.
|
|
692
784
|
"""
|
|
693
785
|
|
|
694
|
-
for user in self.get_subjects_metadata(
|
|
695
|
-
if user["patient_secret_name"] == subject_name:
|
|
786
|
+
for user in self.get_subjects_metadata():
|
|
787
|
+
if user["patient_secret_name"] == str(subject_name) and user["ssid"] == str(ssid):
|
|
696
788
|
return int(user["_id"])
|
|
697
789
|
return False
|
|
698
790
|
|
|
699
|
-
def get_subject(self, subject_name, cache=True):
|
|
700
|
-
"""
|
|
701
|
-
Return a subject object, representing a subject from the project.
|
|
702
|
-
|
|
703
|
-
Parameters
|
|
704
|
-
----------
|
|
705
|
-
subject_name : str
|
|
706
|
-
Name of the subject.
|
|
707
|
-
cache: bool
|
|
708
|
-
Whether to use the cached metadata or not
|
|
709
|
-
|
|
710
|
-
Returns
|
|
711
|
-
-------
|
|
712
|
-
Subject or bool
|
|
713
|
-
A Subject instance representing the desired subject, or
|
|
714
|
-
False if the subject was not found.
|
|
715
|
-
|
|
716
|
-
"""
|
|
717
|
-
subject_id = self.get_subject_id(subject_name, cache=cache)
|
|
718
|
-
if subject_id is False:
|
|
719
|
-
return False
|
|
720
|
-
subj = Subject(subject_name)
|
|
721
|
-
subj.subject_id = subject_id
|
|
722
|
-
subj.project = self
|
|
723
|
-
return subj
|
|
724
|
-
|
|
725
791
|
def add_subject(self, subject):
|
|
726
792
|
"""
|
|
727
793
|
Add a subject to the project.
|
|
@@ -738,37 +804,119 @@ class Project:
|
|
|
738
804
|
"""
|
|
739
805
|
logger = logging.getLogger(logger_name)
|
|
740
806
|
if self.check_subject_name(subject.name):
|
|
741
|
-
logger.error(f"Subject with name {subject.name} already exists in "
|
|
742
|
-
f"project!")
|
|
807
|
+
logger.error(f"Subject with name {subject.name} already exists in " f"project!")
|
|
743
808
|
return False
|
|
744
809
|
|
|
745
810
|
try:
|
|
746
|
-
platform.parse_response(
|
|
747
|
-
self._account.auth, "patient_manager/upsert_patient",
|
|
748
|
-
|
|
749
|
-
))
|
|
811
|
+
platform.parse_response(
|
|
812
|
+
platform.post(self._account.auth, "patient_manager/upsert_patient", data={"secret_name": subject.name})
|
|
813
|
+
)
|
|
750
814
|
except errors.PlatformError:
|
|
751
815
|
logger.error(f"Subject {subject.name} could not be created.")
|
|
752
816
|
return False
|
|
753
817
|
|
|
754
818
|
subject.subject_id = self.get_subject_id(subject.name)
|
|
755
819
|
subject.project = self
|
|
756
|
-
logger.info(
|
|
757
|
-
|
|
820
|
+
logger.info("Subject {0} was successfully created".format(subject.name))
|
|
821
|
+
return True
|
|
822
|
+
|
|
823
|
+
def change_subject_metadata(self, patient_id, subject_name, ssid, tags, age_at_scan, metadata):
|
|
824
|
+
"""
|
|
825
|
+
Change the Subject ID, Session ID, Tags, Age at Scan and Metadata of
|
|
826
|
+
the session with Patient ID
|
|
827
|
+
|
|
828
|
+
Parameters
|
|
829
|
+
----------
|
|
830
|
+
patient_id : Integer
|
|
831
|
+
Patient ID representing the session to modify.
|
|
832
|
+
subject_name : String
|
|
833
|
+
Represents the new Subject ID.
|
|
834
|
+
ssid : String
|
|
835
|
+
Represents the new Session ID.
|
|
836
|
+
tags : list of strings in lowercase
|
|
837
|
+
Represents the new tags of the session.
|
|
838
|
+
age_at_scan : Integer
|
|
839
|
+
Represents the new Age at Scan of the Session.
|
|
840
|
+
metadata : Dictionary
|
|
841
|
+
Each pair key/value representing the new metadata values.
|
|
842
|
+
|
|
843
|
+
The keys must either all start with "md\\_" or none start
|
|
844
|
+
with "md\\_".
|
|
845
|
+
|
|
846
|
+
The key represents the ID of the metadata field.
|
|
847
|
+
|
|
848
|
+
Returns
|
|
849
|
+
-------
|
|
850
|
+
bool
|
|
851
|
+
True if correctly modified, False otherwise
|
|
852
|
+
"""
|
|
853
|
+
logger = logging.getLogger(logger_name)
|
|
854
|
+
|
|
855
|
+
try:
|
|
856
|
+
patient_id = str(int(patient_id))
|
|
857
|
+
except ValueError:
|
|
858
|
+
raise ValueError(f"'patient_id': '{patient_id}' not valid. " f"Must be convertible to int.")
|
|
859
|
+
|
|
860
|
+
assert isinstance(tags, list) and all(
|
|
861
|
+
isinstance(item, str) for item in tags
|
|
862
|
+
), f"tags: '{tags}' should be a list of strings."
|
|
863
|
+
tags = [tag.lower() for tag in tags]
|
|
864
|
+
|
|
865
|
+
assert subject_name is not None and subject_name != "", "subject_name must be a non empty string."
|
|
866
|
+
assert ssid is not None and ssid != "", "ssid must be a non empty string."
|
|
867
|
+
|
|
868
|
+
try:
|
|
869
|
+
age_at_scan = str(int(age_at_scan)) if age_at_scan else None
|
|
870
|
+
except ValueError:
|
|
871
|
+
raise ValueError(f"age_at_scan: '{age_at_scan}' not valid. " f"Must be an integer.")
|
|
872
|
+
|
|
873
|
+
assert isinstance(metadata, dict), f"metadata: '{metadata}' should be a dictionary."
|
|
874
|
+
|
|
875
|
+
assert all("md_" == key[:3] for key in metadata.keys()) or all("md_" != key[:3] for key in metadata.keys()), (
|
|
876
|
+
f"metadata: '{metadata}' must be a dictionary whose keys " f"are either all starting with 'md_' or none."
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
metadata_keys = self.metadata_parameters.keys()
|
|
880
|
+
assert all(
|
|
881
|
+
[key[3:] in metadata_keys if "md_" == key[:3] else key in metadata_keys for key in metadata.keys()]
|
|
882
|
+
), (
|
|
883
|
+
f"Some metadata keys provided ({', '.join(metadata.keys())}) "
|
|
884
|
+
f"are not available in the project. They can be added via the "
|
|
885
|
+
f"Metadata Manager via the QMENTA Platform graphical user "
|
|
886
|
+
f"interface (GUI)."
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
post_data = {
|
|
890
|
+
"patient_id": patient_id,
|
|
891
|
+
"secret_name": str(subject_name),
|
|
892
|
+
"ssid": str(ssid),
|
|
893
|
+
"tags": ",".join(tags),
|
|
894
|
+
"age_at_scan": age_at_scan,
|
|
895
|
+
}
|
|
896
|
+
for key, value in metadata.items():
|
|
897
|
+
id = key[3:] if "md_" == key[:3] else key
|
|
898
|
+
post_data[f"last_vals.{id}"] = value
|
|
899
|
+
|
|
900
|
+
try:
|
|
901
|
+
platform.parse_response(platform.post(self._account.auth, "patient_manager/upsert_patient", data=post_data))
|
|
902
|
+
except errors.PlatformError:
|
|
903
|
+
logger.error(f"Patient ID '{patient_id}' could not be modified.")
|
|
904
|
+
return False
|
|
905
|
+
|
|
906
|
+
logger.info(f"Patient ID '{patient_id}' successfully modified.")
|
|
758
907
|
return True
|
|
759
908
|
|
|
760
|
-
def delete_session(self, subject_name, session_id
|
|
909
|
+
def delete_session(self, subject_name, session_id):
|
|
761
910
|
"""
|
|
762
|
-
Delete a session from a subject within a project
|
|
911
|
+
Delete a session from a subject within a project providing the
|
|
912
|
+
Subject ID and Session ID.
|
|
763
913
|
|
|
764
914
|
Parameters
|
|
765
915
|
----------
|
|
766
916
|
subject_name : str
|
|
767
|
-
|
|
917
|
+
Subject ID of the subject
|
|
768
918
|
session_id : int
|
|
769
|
-
The
|
|
770
|
-
cache : bool
|
|
771
|
-
Whether to use the cached metadata or not
|
|
919
|
+
The Session ID of the session that will be deleted
|
|
772
920
|
|
|
773
921
|
Returns
|
|
774
922
|
-------
|
|
@@ -776,58 +924,78 @@ class Project:
|
|
|
776
924
|
True if correctly deleted, False otherwise.
|
|
777
925
|
"""
|
|
778
926
|
logger = logging.getLogger(logger_name)
|
|
779
|
-
all_sessions = self.get_subjects_metadata(
|
|
927
|
+
all_sessions = self.get_subjects_metadata()
|
|
780
928
|
|
|
781
|
-
|
|
782
|
-
s for s in all_sessions if
|
|
783
|
-
s["patient_secret_name"] == subject_name and int(
|
|
784
|
-
s["ssid"]
|
|
785
|
-
) == session_id
|
|
929
|
+
session_to_del = [
|
|
930
|
+
s for s in all_sessions if s["patient_secret_name"] == subject_name and s["ssid"] == session_id
|
|
786
931
|
]
|
|
787
932
|
|
|
788
|
-
if not
|
|
789
|
-
logger.error(
|
|
790
|
-
f"Session {subject_name}/{session_id} could not be found "
|
|
791
|
-
f"in this project."
|
|
792
|
-
)
|
|
933
|
+
if not session_to_del:
|
|
934
|
+
logger.error(f"Session {subject_name}/{session_id} could not be found " f"in this project.")
|
|
793
935
|
return False
|
|
794
|
-
elif len(
|
|
795
|
-
raise RuntimeError(
|
|
796
|
-
"Multiple sessions with same SID and SSID. Contact support."
|
|
797
|
-
)
|
|
936
|
+
elif len(session_to_del) > 1:
|
|
937
|
+
raise RuntimeError("Multiple sessions with same Subject ID and Session ID." " Contact support.")
|
|
798
938
|
else:
|
|
799
|
-
logger.info("{}/{} found (id {})".format(
|
|
800
|
-
subject_name, session_id, sessions_to_del[0]["_id"]
|
|
801
|
-
))
|
|
939
|
+
logger.info("{}/{} found (id {})".format(subject_name, session_id, session_to_del[0]["_id"]))
|
|
802
940
|
|
|
803
|
-
session =
|
|
941
|
+
session = session_to_del[0]
|
|
804
942
|
|
|
805
943
|
try:
|
|
806
|
-
platform.parse_response(
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
"
|
|
810
|
-
|
|
811
|
-
|
|
944
|
+
platform.parse_response(
|
|
945
|
+
platform.post(
|
|
946
|
+
self._account.auth,
|
|
947
|
+
"patient_manager/delete_patient",
|
|
948
|
+
data={"patient_id": str(int(session["_id"])), "delete_files": 1},
|
|
949
|
+
)
|
|
950
|
+
)
|
|
812
951
|
except errors.PlatformError:
|
|
813
|
-
logger.error(f"Session \"{subject_name}/{session['ssid']}\" could"
|
|
814
|
-
f" not be deleted.")
|
|
952
|
+
logger.error(f"Session \"{subject_name}/{session['ssid']}\" could" f" not be deleted.")
|
|
815
953
|
return False
|
|
816
954
|
|
|
817
|
-
logger.info(
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
955
|
+
logger.info(f"Session \"{subject_name}/{session['ssid']}\" successfully " f"deleted.")
|
|
956
|
+
return True
|
|
957
|
+
|
|
958
|
+
def delete_session_by_patientid(self, patient_id):
|
|
959
|
+
"""
|
|
960
|
+
Delete a session from a subject within a project providing the
|
|
961
|
+
Patient ID.
|
|
962
|
+
|
|
963
|
+
Parameters
|
|
964
|
+
----------
|
|
965
|
+
patient_id : str
|
|
966
|
+
Patient ID of the Session ID/Subject ID
|
|
967
|
+
|
|
968
|
+
Returns
|
|
969
|
+
-------
|
|
970
|
+
bool
|
|
971
|
+
True if correctly deleted, False otherwise.
|
|
972
|
+
"""
|
|
973
|
+
logger = logging.getLogger(logger_name)
|
|
974
|
+
|
|
975
|
+
try:
|
|
976
|
+
platform.parse_response(
|
|
977
|
+
platform.post(
|
|
978
|
+
self._account.auth,
|
|
979
|
+
"patient_manager/delete_patient",
|
|
980
|
+
data={"patient_id": str(int(patient_id)), "delete_files": 1},
|
|
981
|
+
)
|
|
982
|
+
)
|
|
983
|
+
except errors.PlatformError:
|
|
984
|
+
logger.error(f"Patient ID {patient_id} could not be deleted.")
|
|
985
|
+
return False
|
|
986
|
+
|
|
987
|
+
logger.info(f"Patient ID {patient_id} successfully deleted.")
|
|
821
988
|
return True
|
|
822
989
|
|
|
823
990
|
def delete_subject(self, subject_name):
|
|
824
991
|
"""
|
|
825
|
-
Delete a subject from the project.
|
|
992
|
+
Delete a subject from the project. It deletes all its available
|
|
993
|
+
sessions only providing the Subject ID.
|
|
826
994
|
|
|
827
995
|
Parameters
|
|
828
996
|
----------
|
|
829
997
|
subject_name : str
|
|
830
|
-
|
|
998
|
+
Subject ID of the subject to be deleted.
|
|
831
999
|
|
|
832
1000
|
Returns
|
|
833
1001
|
-------
|
|
@@ -837,34 +1005,38 @@ class Project:
|
|
|
837
1005
|
|
|
838
1006
|
logger = logging.getLogger(logger_name)
|
|
839
1007
|
# Always fetch the session IDs from the platform before deleting them
|
|
840
|
-
all_sessions = self.get_subjects_metadata(
|
|
1008
|
+
all_sessions = self.get_subjects_metadata()
|
|
841
1009
|
|
|
842
|
-
sessions_to_del = [
|
|
843
|
-
s for s in all_sessions if s["patient_secret_name"] == subject_name
|
|
844
|
-
]
|
|
1010
|
+
sessions_to_del = [s for s in all_sessions if s["patient_secret_name"] == subject_name]
|
|
845
1011
|
|
|
846
1012
|
if not sessions_to_del:
|
|
847
|
-
logger.error(
|
|
848
|
-
"Subject {} cannot be found in this project.".format(
|
|
849
|
-
subject_name
|
|
850
|
-
)
|
|
851
|
-
)
|
|
1013
|
+
logger.error("Subject {} cannot be found in this project.".format(subject_name))
|
|
852
1014
|
return False
|
|
853
1015
|
|
|
854
1016
|
for ssid in [s["ssid"] for s in sessions_to_del]:
|
|
855
|
-
if not self.delete_session(subject_name, ssid
|
|
1017
|
+
if not self.delete_session(subject_name, ssid):
|
|
856
1018
|
return False
|
|
857
1019
|
return True
|
|
858
1020
|
|
|
859
|
-
def _upload_chunk(
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1021
|
+
def _upload_chunk(
|
|
1022
|
+
self,
|
|
1023
|
+
data,
|
|
1024
|
+
range_str,
|
|
1025
|
+
length,
|
|
1026
|
+
session_id,
|
|
1027
|
+
disposition,
|
|
1028
|
+
last_chunk,
|
|
1029
|
+
name="",
|
|
1030
|
+
date_of_scan="",
|
|
1031
|
+
description="",
|
|
1032
|
+
subject_name="",
|
|
1033
|
+
ssid="",
|
|
1034
|
+
filename="DATA.zip",
|
|
1035
|
+
input_data_type="mri_brain_data:1.0",
|
|
1036
|
+
result=False,
|
|
1037
|
+
add_to_container_id=0,
|
|
1038
|
+
split_data=False,
|
|
1039
|
+
):
|
|
868
1040
|
"""
|
|
869
1041
|
Upload a chunk of a file to the platform.
|
|
870
1042
|
|
|
@@ -891,10 +1063,11 @@ class Project:
|
|
|
891
1063
|
"""
|
|
892
1064
|
|
|
893
1065
|
request_headers = {
|
|
894
|
-
"Content-Type": "application/zip",
|
|
895
|
-
|
|
1066
|
+
"Content-Type": "application/zip",
|
|
1067
|
+
"Content-Range": range_str,
|
|
1068
|
+
"Session-ID": str(session_id),
|
|
896
1069
|
"Content-Length": str(length),
|
|
897
|
-
"Content-Disposition": disposition
|
|
1070
|
+
"Content-Disposition": disposition,
|
|
898
1071
|
}
|
|
899
1072
|
|
|
900
1073
|
if last_chunk:
|
|
@@ -922,31 +1095,36 @@ class Project:
|
|
|
922
1095
|
|
|
923
1096
|
response_time = 900.0 if last_chunk else 120.0
|
|
924
1097
|
response = platform.post(
|
|
925
|
-
auth=self._account.auth,
|
|
926
|
-
endpoint="upload",
|
|
927
|
-
data=data,
|
|
928
|
-
headers=request_headers,
|
|
929
|
-
timeout=response_time
|
|
1098
|
+
auth=self._account.auth, endpoint="upload", data=data, headers=request_headers, timeout=response_time
|
|
930
1099
|
)
|
|
931
1100
|
|
|
932
1101
|
return response
|
|
933
1102
|
|
|
934
|
-
def upload_file(
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1103
|
+
def upload_file(
|
|
1104
|
+
self,
|
|
1105
|
+
file_path,
|
|
1106
|
+
subject_name,
|
|
1107
|
+
ssid="",
|
|
1108
|
+
date_of_scan="",
|
|
1109
|
+
description="",
|
|
1110
|
+
result=False,
|
|
1111
|
+
name="",
|
|
1112
|
+
input_data_type="qmenta_mri_brain_data:1.0",
|
|
1113
|
+
add_to_container_id=0,
|
|
1114
|
+
chunk_size=2**9,
|
|
1115
|
+
split_data=False,
|
|
1116
|
+
):
|
|
939
1117
|
"""
|
|
940
|
-
Upload a file to the platform
|
|
1118
|
+
Upload a ZIP file to the platform.
|
|
941
1119
|
|
|
942
1120
|
Parameters
|
|
943
1121
|
----------
|
|
944
1122
|
file_path : str
|
|
945
|
-
Path to the file to upload.
|
|
1123
|
+
Path to the ZIP file to upload.
|
|
946
1124
|
subject_name : str
|
|
947
|
-
Subject to
|
|
1125
|
+
Subject ID of the data to upload in the project in QMENTA Platform.
|
|
948
1126
|
ssid : str
|
|
949
|
-
|
|
1127
|
+
Session ID of the Subject ID (i.e., ID of the timepoint).
|
|
950
1128
|
date_of_scan : str
|
|
951
1129
|
Date of scan/creation of the file
|
|
952
1130
|
description : str
|
|
@@ -956,7 +1134,7 @@ class Project:
|
|
|
956
1134
|
name : str
|
|
957
1135
|
Name of the file in the platform
|
|
958
1136
|
input_data_type : str
|
|
959
|
-
|
|
1137
|
+
qmenta_medical_image_data:3.11
|
|
960
1138
|
add_to_container_id : int
|
|
961
1139
|
ID of the container to which this file should be added (if id > 0)
|
|
962
1140
|
chunk_size : int
|
|
@@ -999,8 +1177,7 @@ class Project:
|
|
|
999
1177
|
last_chunk = False
|
|
1000
1178
|
|
|
1001
1179
|
if ssid and split_data:
|
|
1002
|
-
logger.warning("split-data argument will be ignored because" +
|
|
1003
|
-
" ssid has been specified")
|
|
1180
|
+
logger.warning("split-data argument will be ignored because" + " ssid has been specified")
|
|
1004
1181
|
split_data = False
|
|
1005
1182
|
|
|
1006
1183
|
while True:
|
|
@@ -1017,16 +1194,27 @@ class Project:
|
|
|
1017
1194
|
end_position = total_bytes - 1
|
|
1018
1195
|
bytes_to_send = total_bytes - uploaded_bytes
|
|
1019
1196
|
|
|
1020
|
-
bytes_range = "bytes " + str(start_position) + "-" +
|
|
1021
|
-
str(end_position) + "/" + str(total_bytes)
|
|
1197
|
+
bytes_range = "bytes " + str(start_position) + "-" + str(end_position) + "/" + str(total_bytes)
|
|
1022
1198
|
|
|
1023
1199
|
dispstr = f"attachment; filename={filename}"
|
|
1024
1200
|
response = self._upload_chunk(
|
|
1025
|
-
data,
|
|
1201
|
+
data,
|
|
1202
|
+
bytes_range,
|
|
1203
|
+
bytes_to_send,
|
|
1204
|
+
session_id,
|
|
1205
|
+
dispstr,
|
|
1026
1206
|
last_chunk,
|
|
1027
|
-
name,
|
|
1028
|
-
|
|
1029
|
-
|
|
1207
|
+
name,
|
|
1208
|
+
date_of_scan,
|
|
1209
|
+
description,
|
|
1210
|
+
subject_name,
|
|
1211
|
+
ssid,
|
|
1212
|
+
filename,
|
|
1213
|
+
input_data_type,
|
|
1214
|
+
result,
|
|
1215
|
+
add_to_container_id,
|
|
1216
|
+
split_data,
|
|
1217
|
+
)
|
|
1030
1218
|
|
|
1031
1219
|
if response is None:
|
|
1032
1220
|
retries_count += 1
|
|
@@ -1046,17 +1234,14 @@ class Project:
|
|
|
1046
1234
|
retries_count += 1
|
|
1047
1235
|
time.sleep(retries_count * 5)
|
|
1048
1236
|
if retries_count > self.max_retries:
|
|
1049
|
-
error_message = (
|
|
1050
|
-
"Error Code: 416; "
|
|
1051
|
-
"Requested Range Not Satisfiable (NGINX)")
|
|
1237
|
+
error_message = "Error Code: 416; " "Requested Range Not Satisfiable (NGINX)"
|
|
1052
1238
|
logger.error(error_message)
|
|
1053
1239
|
break
|
|
1054
1240
|
else:
|
|
1055
1241
|
retries_count += 1
|
|
1056
1242
|
time.sleep(retries_count * 5)
|
|
1057
1243
|
if retries_count > max_retries:
|
|
1058
|
-
error_message =
|
|
1059
|
-
"Upload process stops here !")
|
|
1244
|
+
error_message = "Number of retries has been reached. " "Upload process stops here !"
|
|
1060
1245
|
logger.error(error_message)
|
|
1061
1246
|
break
|
|
1062
1247
|
|
|
@@ -1110,9 +1295,7 @@ class Project:
|
|
|
1110
1295
|
"""
|
|
1111
1296
|
|
|
1112
1297
|
if check_upload_file(file_path):
|
|
1113
|
-
return self.upload_file(
|
|
1114
|
-
file_path, subject_name,
|
|
1115
|
-
input_data_type="parkinson_gametection")
|
|
1298
|
+
return self.upload_file(file_path, subject_name, input_data_type="parkinson_gametection")
|
|
1116
1299
|
return False
|
|
1117
1300
|
|
|
1118
1301
|
def upload_result(self, file_path, subject_name):
|
|
@@ -1156,13 +1339,9 @@ class Project:
|
|
|
1156
1339
|
p_id = int(project_id)
|
|
1157
1340
|
elif type(project_id) == str:
|
|
1158
1341
|
projects = self._account.projects
|
|
1159
|
-
projects_match = [proj for proj in projects
|
|
1160
|
-
if proj["name"] == project_id]
|
|
1342
|
+
projects_match = [proj for proj in projects if proj["name"] == project_id]
|
|
1161
1343
|
if not projects_match:
|
|
1162
|
-
raise Exception(
|
|
1163
|
-
f"Project {project_id}" +
|
|
1164
|
-
" does not exist or is not available for this user."
|
|
1165
|
-
)
|
|
1344
|
+
raise Exception(f"Project {project_id}" + " does not exist or is not available for this user.")
|
|
1166
1345
|
p_id = int(projects_match[0]["id"])
|
|
1167
1346
|
else:
|
|
1168
1347
|
raise TypeError("project_id")
|
|
@@ -1172,30 +1351,26 @@ class Project:
|
|
|
1172
1351
|
}
|
|
1173
1352
|
|
|
1174
1353
|
try:
|
|
1175
|
-
platform.parse_response(
|
|
1176
|
-
self._account.auth,
|
|
1177
|
-
"file_manager/copy_container_to_another_project",
|
|
1178
|
-
data=data
|
|
1179
|
-
))
|
|
1180
|
-
except errors.PlatformError as e:
|
|
1181
|
-
logging.getLogger(logger_name).error(
|
|
1182
|
-
"Couldn not copy container: {}".format(e)
|
|
1354
|
+
platform.parse_response(
|
|
1355
|
+
platform.post(self._account.auth, "file_manager/copy_container_to_another_project", data=data)
|
|
1183
1356
|
)
|
|
1357
|
+
except errors.PlatformError as e:
|
|
1358
|
+
logging.getLogger(logger_name).error("Couldn not copy container: {}".format(e))
|
|
1184
1359
|
return False
|
|
1185
1360
|
|
|
1186
1361
|
return True
|
|
1187
1362
|
|
|
1188
1363
|
def start_analysis(
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1364
|
+
self,
|
|
1365
|
+
script_name,
|
|
1366
|
+
version,
|
|
1367
|
+
in_container_id=None,
|
|
1368
|
+
analysis_name=None,
|
|
1369
|
+
analysis_description=None,
|
|
1370
|
+
ignore_warnings=False,
|
|
1371
|
+
settings=None,
|
|
1372
|
+
tags=None,
|
|
1373
|
+
preferred_destination=None,
|
|
1199
1374
|
):
|
|
1200
1375
|
"""
|
|
1201
1376
|
Starts an analysis on a subject.
|
|
@@ -1234,13 +1409,9 @@ class Project:
|
|
|
1234
1409
|
logger = logging.getLogger(logger_name)
|
|
1235
1410
|
|
|
1236
1411
|
if in_container_id is None and settings is None:
|
|
1237
|
-
raise ValueError(
|
|
1238
|
-
"Pass a value for either in_container_id or settings.")
|
|
1412
|
+
raise ValueError("Pass a value for either in_container_id or settings.")
|
|
1239
1413
|
|
|
1240
|
-
post_data = {
|
|
1241
|
-
"script_name": script_name,
|
|
1242
|
-
"version": version
|
|
1243
|
-
}
|
|
1414
|
+
post_data = {"script_name": script_name, "version": version}
|
|
1244
1415
|
|
|
1245
1416
|
settings = settings or {}
|
|
1246
1417
|
|
|
@@ -1270,9 +1441,7 @@ class Project:
|
|
|
1270
1441
|
post_data["preferred_destination"] = preferred_destination
|
|
1271
1442
|
|
|
1272
1443
|
logger.debug(f"post_data = {post_data}")
|
|
1273
|
-
return self.__handle_start_analysis(
|
|
1274
|
-
post_data, ignore_warnings=ignore_warnings
|
|
1275
|
-
)
|
|
1444
|
+
return self.__handle_start_analysis(post_data, ignore_warnings=ignore_warnings)
|
|
1276
1445
|
|
|
1277
1446
|
def delete_analysis(self, analysis_id):
|
|
1278
1447
|
"""
|
|
@@ -1284,19 +1453,20 @@ class Project:
|
|
|
1284
1453
|
logger = logging.getLogger(logger_name)
|
|
1285
1454
|
|
|
1286
1455
|
try:
|
|
1287
|
-
platform.parse_response(
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1456
|
+
platform.parse_response(
|
|
1457
|
+
platform.post(
|
|
1458
|
+
auth=self._account.auth,
|
|
1459
|
+
endpoint="analysis_manager/delete_analysis",
|
|
1460
|
+
data={"project_id": analysis_id},
|
|
1461
|
+
)
|
|
1462
|
+
)
|
|
1292
1463
|
except errors.PlatformError as error:
|
|
1293
1464
|
logger.error("Could not delete analysis: {}".format(error))
|
|
1294
1465
|
return False
|
|
1295
1466
|
|
|
1296
1467
|
return True
|
|
1297
1468
|
|
|
1298
|
-
def __handle_start_analysis(self, post_data, ignore_warnings=False,
|
|
1299
|
-
n_calls=0):
|
|
1469
|
+
def __handle_start_analysis(self, post_data, ignore_warnings=False, n_calls=0):
|
|
1300
1470
|
"""
|
|
1301
1471
|
Handle the possible responses from the server after start_analysis.
|
|
1302
1472
|
Sometimes we have to send a request again, and then check again the
|
|
@@ -1311,16 +1481,16 @@ class Project:
|
|
|
1311
1481
|
|
|
1312
1482
|
logger = logging.getLogger(logger_name)
|
|
1313
1483
|
if n_calls > call_limit:
|
|
1314
|
-
logger.error(
|
|
1315
|
-
|
|
1484
|
+
logger.error(
|
|
1485
|
+
f"__handle_start_analysis_response called itself more\
|
|
1486
|
+
than {n_calls} times: aborting."
|
|
1487
|
+
)
|
|
1316
1488
|
return None
|
|
1317
1489
|
|
|
1318
1490
|
try:
|
|
1319
|
-
response = platform.parse_response(
|
|
1320
|
-
self._account.auth,
|
|
1321
|
-
|
|
1322
|
-
data=post_data
|
|
1323
|
-
))
|
|
1491
|
+
response = platform.parse_response(
|
|
1492
|
+
platform.post(self._account.auth, "analysis_manager/analysis_registration", data=post_data)
|
|
1493
|
+
)
|
|
1324
1494
|
logger.info(response["message"])
|
|
1325
1495
|
return int(response["analysis_id"])
|
|
1326
1496
|
except platform.ChooseDataError as choose_data:
|
|
@@ -1342,8 +1512,7 @@ class Project:
|
|
|
1342
1512
|
chosen_files = {}
|
|
1343
1513
|
for settings_key in choose_data.data_to_choose:
|
|
1344
1514
|
chosen_files[settings_key] = {}
|
|
1345
|
-
filters = choose_data.data_to_choose[
|
|
1346
|
-
settings_key]["filters"]
|
|
1515
|
+
filters = choose_data.data_to_choose[settings_key]["filters"]
|
|
1347
1516
|
for filter_key in filters:
|
|
1348
1517
|
filter_data = filters[filter_key]
|
|
1349
1518
|
|
|
@@ -1355,35 +1524,24 @@ class Project:
|
|
|
1355
1524
|
if filter_data["range"][0] != 0:
|
|
1356
1525
|
number_of_files_to_select = filter_data["range"][0]
|
|
1357
1526
|
elif filter_data["range"][1] != 0:
|
|
1358
|
-
number_of_files_to_select = min(
|
|
1359
|
-
filter_data["range"][1],
|
|
1360
|
-
len(filter_data["files"])
|
|
1361
|
-
)
|
|
1527
|
+
number_of_files_to_select = min(filter_data["range"][1], len(filter_data["files"]))
|
|
1362
1528
|
else:
|
|
1363
|
-
number_of_files_to_select = len(
|
|
1364
|
-
filter_data["files"]
|
|
1365
|
-
)
|
|
1529
|
+
number_of_files_to_select = len(filter_data["files"])
|
|
1366
1530
|
|
|
1367
|
-
files_selection = [ff["_id"] for ff in
|
|
1368
|
-
|
|
1369
|
-
[:number_of_files_to_select]]
|
|
1370
|
-
chosen_files[settings_key][filter_key] = \
|
|
1371
|
-
files_selection
|
|
1531
|
+
files_selection = [ff["_id"] for ff in filter_data["files"][:number_of_files_to_select]]
|
|
1532
|
+
chosen_files[settings_key][filter_key] = files_selection
|
|
1372
1533
|
|
|
1373
1534
|
new_post["user_preference"] = json.dumps(chosen_files)
|
|
1374
1535
|
else:
|
|
1375
1536
|
if has_warning and not ignore_warnings:
|
|
1376
|
-
logger.info("cancelling analysis due to warnings, " +
|
|
1377
|
-
"set \"ignore_warnings\" to True to override")
|
|
1537
|
+
logger.info("cancelling analysis due to warnings, " + 'set "ignore_warnings" to True to override')
|
|
1378
1538
|
new_post["cancel"] = "1"
|
|
1379
1539
|
else:
|
|
1380
1540
|
logger.info("suppressing warnings")
|
|
1381
1541
|
new_post["user_preference"] = "{}"
|
|
1382
1542
|
new_post["_mint_only_warning"] = "1"
|
|
1383
1543
|
|
|
1384
|
-
return self.__handle_start_analysis(
|
|
1385
|
-
new_post, ignore_warnings=ignore_warnings, n_calls=n_calls
|
|
1386
|
-
)
|
|
1544
|
+
return self.__handle_start_analysis(new_post, ignore_warnings=ignore_warnings, n_calls=n_calls)
|
|
1387
1545
|
except platform.ActionFailedError as e:
|
|
1388
1546
|
logger.error(f"Unable to start the analysis: {e}")
|
|
1389
1547
|
return None
|
|
@@ -1414,19 +1572,15 @@ class Project:
|
|
|
1414
1572
|
logger = logging.getLogger(__name__)
|
|
1415
1573
|
logger.info(f"Setting QC status to {status}: {comments}")
|
|
1416
1574
|
|
|
1417
|
-
platform.parse_response(
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
"item_ids": analysis_id,
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
"entity": "analysis"
|
|
1425
|
-
}
|
|
1426
|
-
))
|
|
1575
|
+
platform.parse_response(
|
|
1576
|
+
platform.post(
|
|
1577
|
+
auth=self._account.auth,
|
|
1578
|
+
endpoint="projectset_manager/set_qa_status",
|
|
1579
|
+
data={"item_ids": analysis_id, "status": status.value, "comments": comments, "entity": "analysis"},
|
|
1580
|
+
)
|
|
1581
|
+
)
|
|
1427
1582
|
|
|
1428
|
-
def get_qc_status(
|
|
1429
|
-
self, patient_secret_name=None, ssid=None, analysis_id=None):
|
|
1583
|
+
def get_qc_status(self, patient_secret_name=None, ssid=None, analysis_id=None):
|
|
1430
1584
|
"""
|
|
1431
1585
|
Gets the session QC status of a session. If the analysis_id is
|
|
1432
1586
|
specified, it returns the QC of the
|
|
@@ -1436,17 +1590,15 @@ class Project:
|
|
|
1436
1590
|
if patient_secret_name and ssid:
|
|
1437
1591
|
session = self.get_subjects_metadata(
|
|
1438
1592
|
search_criteria={
|
|
1439
|
-
"pars_patient_secret_name": f"string;"
|
|
1440
|
-
|
|
1441
|
-
"pars_ssid": f"integer;eq|{ssid}"
|
|
1593
|
+
"pars_patient_secret_name": f"string;" f"{patient_secret_name}",
|
|
1594
|
+
"pars_ssid": f"integer;eq|{ssid}",
|
|
1442
1595
|
}
|
|
1443
1596
|
)
|
|
1444
1597
|
to_return = session["qa_status"], session["qa_comments"]
|
|
1445
1598
|
elif analysis_id:
|
|
1446
1599
|
try:
|
|
1447
1600
|
to_return = [
|
|
1448
|
-
analysis["qa_data"] for analysis in self.list_analysis()
|
|
1449
|
-
if analysis["_id"] == analysis_id
|
|
1601
|
+
analysis["qa_data"] for analysis in self.list_analysis() if analysis["_id"] == analysis_id
|
|
1450
1602
|
][0]
|
|
1451
1603
|
to_return = to_return["qa_status"], to_return["qa_comments"]
|
|
1452
1604
|
except IndexError:
|
|
@@ -1457,22 +1609,21 @@ class Project:
|
|
|
1457
1609
|
print(f"An error occurred: {e}")
|
|
1458
1610
|
to_return = None
|
|
1459
1611
|
else:
|
|
1460
|
-
raise Exception(f"Must specify {patient_secret_name} and "
|
|
1461
|
-
f"{ssid} or {analysis_id}.")
|
|
1612
|
+
raise Exception(f"Must specify {patient_secret_name} and {ssid} or {analysis_id}.")
|
|
1462
1613
|
return to_return
|
|
1463
1614
|
|
|
1464
1615
|
def start_multiple_analyses(
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1616
|
+
self,
|
|
1617
|
+
script_name,
|
|
1618
|
+
version,
|
|
1619
|
+
n_times,
|
|
1620
|
+
in_container_id=None,
|
|
1621
|
+
analysis_name=None,
|
|
1622
|
+
analysis_description=None,
|
|
1623
|
+
ignore_warnings=False,
|
|
1624
|
+
settings=None,
|
|
1625
|
+
tags=None,
|
|
1626
|
+
preferred_destination=None,
|
|
1476
1627
|
):
|
|
1477
1628
|
"""
|
|
1478
1629
|
Starts multiple times the same analysis on a subject with the same
|
|
@@ -1513,9 +1664,7 @@ class Project:
|
|
|
1513
1664
|
"""
|
|
1514
1665
|
logger = logging.getLogger(logger_name)
|
|
1515
1666
|
for n in range(n_times):
|
|
1516
|
-
logger.info(
|
|
1517
|
-
f"Running tool {script_name}:{version} {n + 1}/{n_times}"
|
|
1518
|
-
)
|
|
1667
|
+
logger.info(f"Running tool {script_name}:{version} {n + 1}/{n_times}")
|
|
1519
1668
|
yield self.start_analysis(
|
|
1520
1669
|
script_name,
|
|
1521
1670
|
version,
|
|
@@ -1525,5 +1674,40 @@ class Project:
|
|
|
1525
1674
|
ignore_warnings=ignore_warnings,
|
|
1526
1675
|
settings=settings,
|
|
1527
1676
|
tags=tags,
|
|
1528
|
-
preferred_destination=preferred_destination
|
|
1677
|
+
preferred_destination=preferred_destination,
|
|
1529
1678
|
)
|
|
1679
|
+
|
|
1680
|
+
def set_project_qa_rules(self, rules_file_path, guidance_text=""):
|
|
1681
|
+
"""
|
|
1682
|
+
Logs in to the Qmenta platform, retrieves the project ID based on the project name,
|
|
1683
|
+
and updates the project's QA rules using the provided rules file.
|
|
1684
|
+
|
|
1685
|
+
Args:
|
|
1686
|
+
rules_file_path (str): The file path to the JSON file containing the QA rules.
|
|
1687
|
+
guidance_text (str): Description of the rules. Only visible for Platform admins.
|
|
1688
|
+
|
|
1689
|
+
Returns:
|
|
1690
|
+
bool: True if the rules were set successfully, False otherwise.
|
|
1691
|
+
"""
|
|
1692
|
+
# Read the rules from the JSON file
|
|
1693
|
+
try:
|
|
1694
|
+
with open(rules_file_path, "r") as fr:
|
|
1695
|
+
rules = json.load(fr)
|
|
1696
|
+
except FileNotFoundError:
|
|
1697
|
+
print(f"ERROR: Rules file '{rules_file_path}' not found.")
|
|
1698
|
+
return False
|
|
1699
|
+
|
|
1700
|
+
# Update the project's QA rules
|
|
1701
|
+
res = platform.post(
|
|
1702
|
+
auth=self._account.auth,
|
|
1703
|
+
endpoint="projectset_manager/set_session_qa_requirements",
|
|
1704
|
+
data={"project_id": self._project_id, "rules": json.dumps(rules), "guidance_text": guidance_text},
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
if res.json().get("success") == 1:
|
|
1708
|
+
print("Rules set up successfully!")
|
|
1709
|
+
return True
|
|
1710
|
+
else:
|
|
1711
|
+
print("ERROR setting the rules")
|
|
1712
|
+
print(res.json())
|
|
1713
|
+
return False
|