Skip to content

Commit 7d4ad95

Browse files
authored
RESTful sample status (#3139)
* TST: tests for the sample status end points * API: add sample status endpoints * Defensive assertion on detail maker * Address @antgonza's comments * Limit memory use when caching prep info
1 parent ab438cc commit 7d4ad95

File tree

3 files changed

+257
-1
lines changed

3 files changed

+257
-1
lines changed

qiita_pet/handlers/rest/__init__.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
from .study import StudyHandler, StudyCreatorHandler, StudyStatusHandler
1010
from .study_samples import (StudySamplesHandler, StudySamplesInfoHandler,
11-
StudySamplesCategoriesHandler)
11+
StudySamplesCategoriesHandler,
12+
StudySamplesDetailHandler,
13+
StudySampleDetailHandler)
1214
from .study_person import StudyPersonHandler
1315
from .study_preparation import (StudyPrepCreatorHandler,
1416
StudyPrepArtifactCreatorHandler)
@@ -26,6 +28,9 @@
2628
(r"/api/v1/study/([0-9]+)/samples/categories=([a-zA-Z\-0-9\.:,_]*)",
2729
StudySamplesCategoriesHandler),
2830
(r"/api/v1/study/([0-9]+)/samples", StudySamplesHandler),
31+
(r"/api/v1/study/([0-9]+)/samples/status", StudySamplesDetailHandler),
32+
(r"/api/v1/study/([0-9]+)/sample/([a-zA-Z\-0-9\.]+)/status",
33+
StudySampleDetailHandler),
2934
(r"/api/v1/study/([0-9]+)/samples/info", StudySamplesInfoHandler),
3035
(r"/api/v1/person(.*)", StudyPersonHandler),
3136
(r"/api/v1/study/([0-9]+)/preparation/([0-9]+)/artifact",

qiita_pet/handlers/rest/study_samples.py

+110
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,123 @@
55
#
66
# The full license is in the file LICENSE, distributed with this software.
77
# -----------------------------------------------------------------------------
8+
from collections import defaultdict
9+
810
from tornado.escape import json_encode, json_decode
911
import pandas as pd
1012

1113
from qiita_db.handlers.oauth2 import authenticate_oauth
1214
from .rest_handler import RESTHandler
1315

1416

17+
def _sample_details(study, samples):
18+
def detail_maker(**kwargs):
19+
base = {'sample_id': None,
20+
'sample_found': False,
21+
'ebi_sample_accession': None,
22+
'preparation_id': None,
23+
'ebi_experiment_accession': None,
24+
'preparation_visibility': None,
25+
'preparation_type': None}
26+
27+
assert set(kwargs).issubset(set(base)), "Unexpected key to set"
28+
29+
base.update(kwargs)
30+
return base
31+
32+
# cache sample detail for lookup
33+
study_samples = set(study.sample_template)
34+
sample_accessions = study.sample_template.ebi_sample_accessions
35+
36+
# cache preparation information that we'll need
37+
38+
# map of {sample_id: [indices, of, light, prep, info, ...]}
39+
sample_prep_mapping = defaultdict(list)
40+
pt_light = []
41+
offset = 0
42+
incoming_samples = set(samples)
43+
for pt in study.prep_templates():
44+
prep_samples = set(pt)
45+
overlap = incoming_samples & prep_samples
46+
47+
if overlap:
48+
# cache if any of or query samples are present on the prep
49+
50+
# reduce accessions to only samples of interest
51+
accessions = pt.ebi_experiment_accessions
52+
overlap_accessions = {i: accessions[i] for i in overlap}
53+
54+
# store the detail we need
55+
pt_light.append((pt.id, overlap_accessions,
56+
pt.status, pt.data_type()))
57+
58+
# only care about mapping the incoming samples
59+
for ptsample in overlap:
60+
sample_prep_mapping[ptsample].append(offset)
61+
62+
offset += 1
63+
64+
details = []
65+
for sample in samples:
66+
if sample in study_samples:
67+
# if the sample exists
68+
sample_acc = sample_accessions.get(sample)
69+
70+
if sample in sample_prep_mapping:
71+
# if the sample is present in any prep, pull out the detail
72+
# specific to those preparations
73+
for pt_idx in sample_prep_mapping[sample]:
74+
ptid, ptacc, ptstatus, ptdtype = pt_light[pt_idx]
75+
76+
details.append(detail_maker(
77+
sample_id=sample,
78+
sample_found=True,
79+
ebi_sample_accession=sample_acc,
80+
preparation_id=ptid,
81+
ebi_experiment_accession=ptacc.get(sample),
82+
preparation_visibility=ptstatus,
83+
preparation_type=ptdtype))
84+
else:
85+
# the sample is not present on any preparations
86+
details.append(detail_maker(
87+
sample_id=sample,
88+
sample_found=True,
89+
90+
# it would be weird to have an EBI sample accession
91+
# but not be present on a preparation...?
92+
ebi_sample_accession=sample_acc))
93+
else:
94+
# the is not present, let's note and move ona
95+
details.append(detail_maker(sample_id=sample))
96+
97+
return details
98+
99+
100+
class StudySampleDetailHandler(RESTHandler):
101+
@authenticate_oauth
102+
def get(self, study_id, sample_id):
103+
study = self.safe_get_study(study_id)
104+
sample_detail = _sample_details(study, [sample_id, ])
105+
self.write(json_encode(sample_detail))
106+
self.finish()
107+
108+
109+
class StudySamplesDetailHandler(RESTHandler):
110+
@authenticate_oauth
111+
def post(self, study_id):
112+
samples = json_decode(self.request.body)
113+
114+
if 'sample_ids' not in samples:
115+
self.fail('Missing sample_id key', 400)
116+
return
117+
118+
study = self.safe_get_study(study_id)
119+
samples_detail = _sample_details(study, samples['sample_ids'])
120+
121+
self.write(json_encode(samples_detail))
122+
self.finish()
123+
124+
15125
class StudySamplesHandler(RESTHandler):
16126

17127
@authenticate_oauth
+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# -----------------------------------------------------------------------------
2+
# Copyright (c) 2014--, The Qiita Development Team.
3+
#
4+
# Distributed under the terms of the BSD 3-clause License.
5+
#
6+
# The full license is in the file LICENSE, distributed with this software.
7+
# -----------------------------------------------------------------------------
8+
9+
from unittest import main, TestCase
10+
11+
from tornado.escape import json_decode
12+
13+
import qiita_db
14+
15+
from qiita_pet.test.rest.test_base import RESTHandlerTestCase
16+
from qiita_pet.handlers.rest.study_samples import _sample_details
17+
18+
19+
class SupportTests(TestCase):
20+
def test_samples_detail(self):
21+
exp = [{'sample_id': '1.SKD7.640191',
22+
'sample_found': True,
23+
'ebi_sample_accession': 'ERS000021',
24+
'preparation_id': 1,
25+
'ebi_experiment_accession': 'ERX0000021',
26+
'preparation_visibility': 'private',
27+
'preparation_type': '18S'},
28+
{'sample_id': '1.SKD7.640191',
29+
'sample_found': True,
30+
'ebi_sample_accession': 'ERS000021',
31+
'preparation_id': 2,
32+
'ebi_experiment_accession': 'ERX0000021',
33+
'preparation_visibility': 'private',
34+
'preparation_type': '18S'},
35+
{'sample_id': 'doesnotexist',
36+
'sample_found': False,
37+
'ebi_sample_accession': None,
38+
'preparation_id': None,
39+
'ebi_experiment_accession': None,
40+
'preparation_visibility': None,
41+
'preparation_type': None}]
42+
obs = _sample_details(qiita_db.study.Study(1),
43+
['1.SKD7.640191', 'doesnotexist'])
44+
self.assertEqual(len(obs), len(exp))
45+
self.assertEqual(obs, exp)
46+
47+
48+
class SampleDetailHandlerTests(RESTHandlerTestCase):
49+
def test_get_missing_sample(self):
50+
exp = [{'sample_id': 'doesnotexist',
51+
'sample_found': False,
52+
'ebi_sample_accession': None,
53+
'preparation_id': None,
54+
'ebi_experiment_accession': None,
55+
'preparation_visibility': None,
56+
'preparation_type': None}, ]
57+
58+
response = self.get('/api/v1/study/1/sample/doesnotexist/status',
59+
headers=self.headers)
60+
self.assertEqual(response.code, 200)
61+
obs = json_decode(response.body)
62+
self.assertEqual(obs, exp)
63+
64+
def test_get_valid_sample(self):
65+
exp = [{'sample_id': '1.SKD7.640191',
66+
'sample_found': True,
67+
'ebi_sample_accession': 'ERS000021',
68+
'preparation_id': 1,
69+
'ebi_experiment_accession': 'ERX0000021',
70+
'preparation_visibility': 'private',
71+
'preparation_type': '18S'},
72+
{'sample_id': '1.SKD7.640191',
73+
'sample_found': True,
74+
'ebi_sample_accession': 'ERS000021',
75+
'preparation_id': 2,
76+
'ebi_experiment_accession': 'ERX0000021',
77+
'preparation_visibility': 'private',
78+
'preparation_type': '18S'}]
79+
80+
response = self.get('/api/v1/study/1/sample/1.SKD7.640191/status',
81+
headers=self.headers)
82+
self.assertEqual(response.code, 200)
83+
obs = json_decode(response.body)
84+
self.assertEqual(obs, exp)
85+
86+
def test_post_samples_status_bad_request(self):
87+
body = {'malformed': 'with garbage'}
88+
response = self.post('/api/v1/study/1/samples/status',
89+
headers=self.headers,
90+
data=body, asjson=True)
91+
self.assertEqual(response.code, 400)
92+
93+
def test_post_samples_status(self):
94+
exp = [{'sample_id': '1.SKD7.640191',
95+
'sample_found': True,
96+
'ebi_sample_accession': 'ERS000021',
97+
'preparation_id': 1,
98+
'ebi_experiment_accession': 'ERX0000021',
99+
'preparation_visibility': 'private',
100+
'preparation_type': '18S'},
101+
{'sample_id': '1.SKD7.640191',
102+
'sample_found': True,
103+
'ebi_sample_accession': 'ERS000021',
104+
'preparation_id': 2,
105+
'ebi_experiment_accession': 'ERX0000021',
106+
'preparation_visibility': 'private',
107+
'preparation_type': '18S'},
108+
{'sample_id': 'doesnotexist',
109+
'sample_found': False,
110+
'ebi_sample_accession': None,
111+
'preparation_id': None,
112+
'ebi_experiment_accession': None,
113+
'preparation_visibility': None,
114+
'preparation_type': None},
115+
{'sample_id': '1.SKM5.640177',
116+
'sample_found': True,
117+
'ebi_sample_accession': 'ERS000005',
118+
'preparation_id': 1,
119+
'ebi_experiment_accession': 'ERX0000005',
120+
'preparation_visibility': 'private',
121+
'preparation_type': '18S'},
122+
{'sample_id': '1.SKM5.640177',
123+
'sample_found': True,
124+
'ebi_sample_accession': 'ERS000005',
125+
'preparation_id': 2,
126+
'ebi_experiment_accession': 'ERX0000005',
127+
'preparation_visibility': 'private',
128+
'preparation_type': '18S'}]
129+
130+
body = {'sample_ids': ['1.SKD7.640191', 'doesnotexist',
131+
'1.SKM5.640177']}
132+
response = self.post('/api/v1/study/1/samples/status',
133+
headers=self.headers,
134+
data=body, asjson=True)
135+
self.assertEqual(response.code, 200)
136+
obs = json_decode(response.body)
137+
self.assertEqual(obs, exp)
138+
139+
140+
if __name__ == '__main__':
141+
main()

0 commit comments

Comments
 (0)