Skip to content

Commit 8c39e79

Browse files
/api/v1/study/1/samples accepts new categories
calling PATCH on /api/v1/study/1/samples now silently accepts metadata with new categories in them when updating a sample or adding a new one. Existing samples will be assigned a null value while samples in the metadata will be assigned the value or empty string if one was not given.
1 parent fed5786 commit 8c39e79

File tree

2 files changed

+199
-15
lines changed

2 files changed

+199
-15
lines changed

qiita_pet/handlers/rest/study_samples.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,7 @@ def patch(self, study_id):
172172
if set(data.columns).issubset(categories):
173173
self.fail('Not all sample information categories provided',
174174
400)
175-
else:
176-
unknown = set(data.columns) - categories
177-
self.fail("Some categories do not exist in the sample "
178-
"information", 400,
179-
categories_not_found=sorted(unknown))
180-
return
175+
return
181176

182177
existing_samples = set(sample_info.index)
183178
overlapping_ids = set(data.index).intersection(existing_samples)
@@ -186,16 +181,20 @@ def patch(self, study_id):
186181

187182
# warnings generated are not currently caught
188183
# see https://github.com/biocore/qiita/issues/2096
189-
if overlapping_ids:
190-
to_update = data.loc[overlapping_ids]
191-
study.sample_template.update(to_update)
192-
status = 200
193184

185+
# processing new_ids first allows us to extend the sample_template
186+
# w/new columns before calling update(). update() will return an
187+
# error if unexpected columns are found.
194188
if new_ids:
195189
to_extend = data.loc[new_ids]
196190
study.sample_template.extend(to_extend)
197191
status = 201
198192

193+
if overlapping_ids:
194+
to_update = data.loc[overlapping_ids]
195+
study.sample_template.update(to_update)
196+
status = 200
197+
199198
self.set_status(status)
200199
self.finish()
201200

qiita_pet/test/rest/test_study_samples.py

Lines changed: 190 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
#
66
# The full license is in the file LICENSE, distributed with this software.
77
# -----------------------------------------------------------------------------
8-
8+
import json
99
from unittest import main
1010
from datetime import datetime
1111

@@ -33,6 +33,120 @@ def _sample_creator(ids):
3333

3434

3535
class StudySamplesHandlerTests(RESTHandlerTestCase):
36+
def test_patch_accept_new_categories(self):
37+
body = {'1.SKM1.999998': {'dna_extracted': 'foo',
38+
'host_taxid': 'foo',
39+
'altitude': 'foo',
40+
'description_duplicate': 'foo',
41+
'temp': 'foo',
42+
'country': 'foo',
43+
'texture': 'foo',
44+
'latitude': '32.7157',
45+
'assigned_from_geo': 'foo',
46+
'tot_org_carb': 'foo',
47+
'env_feature': 'foo',
48+
'depth': 'foo',
49+
'tot_nitro': 'foo',
50+
'anonymized_name': 'foo',
51+
'scientific_name': 'foo',
52+
'samp_salinity': 'foo',
53+
'ph': 'foo',
54+
'taxon_id': '9999',
55+
'season_environment': 'foo',
56+
'physical_specimen_remaining': 'foo',
57+
'host_subject_id': 'foo',
58+
'water_content_soil': 'foo',
59+
'env_biome': 'foo',
60+
'env_package': 'foo',
61+
'elevation': 'foo',
62+
'collection_timestamp': ('2014-05-29 '
63+
'12:24:15'),
64+
'sample_type': 'foo',
65+
'physical_specimen_location': 'foo',
66+
'longitude': '117.1611',
67+
'common_name': 'foo',
68+
'description': 'foo'}}
69+
70+
# first, confirm this should patch successfully: all fields present
71+
# note that response is 201 if using patch to add new samples, 200 if
72+
# updating existing samples.
73+
response = self.patch('/api/v1/study/1/samples', headers=self.headers,
74+
data=body, asjson=True)
75+
self.assertEqual(response.code, 201)
76+
77+
body = {'1.SKM1.999999': {'dna_extracted': 'foo',
78+
'host_taxid': 'foo',
79+
'altitude': 'foo',
80+
'description_duplicate': 'foo',
81+
'temp': 'foo',
82+
'country': 'foo',
83+
'texture': 'foo',
84+
'latitude': '32.7157',
85+
'assigned_from_geo': 'foo',
86+
'tot_org_carb': 'foo',
87+
'env_feature': 'foo',
88+
'depth': 'foo',
89+
'tot_nitro': 'foo',
90+
'anonymized_name': 'foo',
91+
'scientific_name': 'foo',
92+
'samp_salinity': 'foo',
93+
'ph': 'foo',
94+
'taxon_id': '9999',
95+
'season_environment': 'foo',
96+
'physical_specimen_remaining': 'foo',
97+
'host_subject_id': 'foo',
98+
'water_content_soil': 'foo',
99+
'env_biome': 'foo',
100+
'env_package': 'foo',
101+
'elevation': 'foo',
102+
'collection_timestamp': ('2014-05-29 '
103+
'12:24:15'),
104+
'sample_type': 'foo',
105+
'physical_specimen_location': 'foo',
106+
'longitude': '117.1611',
107+
'common_name': 'foo',
108+
'description': 'foo'}}
109+
110+
# add a new field to one sample_id, making body a superset of values.
111+
body['1.SKM1.999999']['new_field1'] = 'some_value'
112+
body['1.SKM1.999999']['new_field2'] = 'another_value'
113+
114+
# this test should pass.
115+
response = self.patch('/api/v1/study/1/samples', headers=self.headers,
116+
data=body, asjson=True)
117+
self.assertEqual(response.code, 201)
118+
119+
# confirm new samples were added.
120+
response = self.get('/api/v1/study/1/samples', headers=self.headers)
121+
self.assertEqual(response.code, 200)
122+
obs = json_decode(response.body)
123+
self.assertIn('1.SKM1.999998', obs)
124+
self.assertIn('1.SKM1.999999', obs)
125+
126+
# confirm new categories are a part of the samples.
127+
response = self.get('/api/v1/study/1/samples/info',
128+
headers=self.headers)
129+
self.assertEqual(response.code, 200)
130+
obs = json_decode(response.body)
131+
self.assertIn('new_field1', obs['categories'])
132+
self.assertIn('new_field2', obs['categories'])
133+
134+
# TODO: need a test to get metadata for 1.SKM1.999998 and 1.SKM1.999999
135+
# and confirm new_field1 and new_field2 are empty for the former and
136+
# filled for the latter.
137+
138+
# remove a few existing fields, representing retired fields.
139+
for sample_id in body:
140+
del (body[sample_id]['ph'])
141+
del (body[sample_id]['water_content_soil'])
142+
143+
exp = {'message': 'Not all sample information categories provided'}
144+
response = self.patch('/api/v1/study/1/samples', headers=self.headers,
145+
data=body, asjson=True)
146+
self.assertEqual(response.code, 400)
147+
obs = json_decode(response.body)
148+
self.assertEqual(obs, exp)
149+
36150
def test_patch_no_study(self):
37151
body = {'sampleid1': {'category_a': 'value_a'},
38152
'sampleid2': {'category_b': 'value_b'}}
@@ -91,13 +205,84 @@ def test_patch_sample_ids_exist_incomplete_metadata(self):
91205
self.assertEqual(obs, exp)
92206

93207
def test_patch_sample_ids_complete_metadata_and_unknown_metadata(self):
208+
response = self.get('/api/v1/study/1/samples', headers=self.headers)
209+
self.assertEqual(response.code, 200)
210+
211+
# If no new categories, both new and existing samples should succeed.
212+
# 640201 is an existing sample. blank.a1 is a new sample
94213
body = _sample_creator(['1.SKM8.640201', 'blank.a1'])
214+
response = self.patch('/api/v1/study/1/samples', headers=self.headers,
215+
data=body, asjson=True)
216+
self.assertEqual(response.code, 200)
217+
# successful response should be empty string
218+
self.assertEqual(response.body, b'')
219+
220+
# If new categories are added, patch() should succeed.
221+
# New and existing samples should have new categories.
222+
# 640201 is an existing sample. blank.a2 is a new sample
223+
body = _sample_creator(['1.SKM8.640201', 'blank.a2'])
224+
# body['blank.a2']['DOES_NOT_EXIST'] will be '', not None.
225+
# body['1.SKM8.640201']['WHAT'] will be '', not None.
95226
body['1.SKM8.640201']['DOES_NOT_EXIST'] = 'foo'
96-
body['blank.a1']['WHAT'] = 'bar'
227+
body['blank.a2']['WHAT'] = 'bar'
228+
229+
response = self.patch('/api/v1/study/1/samples', headers=self.headers,
230+
data=body, asjson=True)
231+
self.assertEqual(response.code, 200)
232+
# successful response should be empty string
233+
self.assertEqual(response.body, b'')
234+
235+
response = self.get(('/api/v1/study/1/samples/categories='
236+
'does_not_exist,what'), headers=self.headers)
237+
self.assertEqual(response.code, 200)
97238

98-
exp = {'message': "Some categories do not exist in the sample "
99-
"information",
100-
'categories_not_found': ['DOES_NOT_EXIST', 'WHAT']}
239+
# decode results manually from bytes, replacing non-JSON-spec 'NaN'
240+
# values with JSON-spec 'null'. These will convert to Python None
241+
# values when load()ed.
242+
obs = response.body.decode("utf-8").replace('NaN', 'null')
243+
obs = json.loads(obs)
244+
245+
exp = {'header': ['does_not_exist', 'what'],
246+
'samples': {'1.SKM7.640188': [None, None],
247+
'1.SKD9.640182': [None, None],
248+
'1.SKB8.640193': [None, None],
249+
'1.SKD2.640178': [None, None],
250+
'1.SKM3.640197': [None, None],
251+
'1.SKM4.640180': [None, None],
252+
'1.SKB9.640200': [None, None],
253+
'1.SKB4.640189': [None, None],
254+
'1.SKB5.640181': [None, None],
255+
'1.SKB6.640176': [None, None],
256+
'1.SKM2.640199': [None, None],
257+
'1.SKM5.640177': [None, None],
258+
'1.SKB1.640202': [None, None],
259+
'1.SKD8.640184': [None, None],
260+
'1.SKD4.640185': [None, None],
261+
'1.SKB3.640195': [None, None],
262+
'1.SKM1.640183': [None, None],
263+
'1.SKB7.640196': [None, None],
264+
'1.SKD3.640198': [None, None],
265+
'1.SKD7.640191': [None, None],
266+
'1.SKD6.640190': [None, None],
267+
'1.SKB2.640194': [None, None],
268+
'1.SKM9.640192': [None, None],
269+
'1.SKM6.640187': [None, None],
270+
'1.SKD5.640186': [None, None],
271+
'1.SKD1.640179': [None, None],
272+
'1.blank.a1': [None, None],
273+
'1.blank.a2': ['', 'bar'],
274+
'1.SKM8.640201': ['foo', '']}}
275+
276+
self.assertDictEqual(obs, exp)
277+
278+
# If categories were removed, both existing and new samples should
279+
# fail.
280+
# 640201 is an existing sample. blank.a3 is a new sample
281+
body = _sample_creator(['1.SKM8.640201', 'blank.a3'])
282+
del (body['1.SKM8.640201']['env_biome'])
283+
del (body['blank.a3']['env_biome'])
284+
285+
exp = {'message': 'Not all sample information categories provided'}
101286
response = self.patch('/api/v1/study/1/samples', headers=self.headers,
102287
data=body, asjson=True)
103288
self.assertEqual(response.code, 400)

0 commit comments

Comments
 (0)