diff --git a/qiita_db/study.py b/qiita_db/study.py index ef21feddc..0b0a7f173 100644 --- a/qiita_db/study.py +++ b/qiita_db/study.py @@ -1213,6 +1213,33 @@ def exists(cls, name, affiliation): qdb.sql_connection.TRN.add(sql, [name, affiliation]) return qdb.sql_connection.TRN.execute_fetchlast() + @classmethod + def from_name_and_affiliation(cls, name, affiliation): + """Gets a StudyPerson object based on the name and affiliation + + Parameters + ---------- + name: str + Name of the person + affiliation : str + institution with which the person is affiliated + + Returns + ------- + StudyPerson + The StudyPerson for the name and affiliation + """ + with qdb.sql_connection.TRN: + if not cls.exists(name, affiliation): + raise qdb.exceptions.QiitaDBLookupError( + 'Study person does not exist') + + sql = """SELECT study_person_id FROM qiita.{0} + WHERE name = %s + AND affiliation = %s""".format(cls._table) + qdb.sql_connection.TRN.add(sql, [name, affiliation]) + return cls(qdb.sql_connection.TRN.execute_fetchlast()) + @classmethod def create(cls, name, email, affiliation, address=None, phone=None): """Create a StudyPerson object, checking if person already exists. diff --git a/qiita_db/test/test_study.py b/qiita_db/test/test_study.py index 2ccf23c67..ce090e2cb 100644 --- a/qiita_db/test/test_study.py +++ b/qiita_db/test/test_study.py @@ -46,6 +46,19 @@ def test_delete(self): self.assertFalse( qdb.study.StudyPerson.exists('SomeDude', 'affil')) + def test_retrieve_non_existant_people(self): + with self.assertRaises(qdb.exceptions.QiitaDBLookupError): + qdb.study.StudyPerson.from_name_and_affiliation('Boaty McBoatFace', + 'UCSD') + + p = qdb.study.StudyPerson.from_name_and_affiliation('LabDude', + 'knight lab') + self.assertEqual(p.name, 'LabDude') + self.assertEqual(p.affiliation, 'knight lab') + self.assertEqual(p.address, '123 lab street') + self.assertEqual(p.phone, '121-222-3333') + self.assertEqual(p.email, 'lab_dude@foo.bar') + def test_iter(self): """Make sure that each and every StudyPerson is retrieved""" expected = [ diff --git a/qiita_pet/handlers/rest/__init__.py b/qiita_pet/handlers/rest/__init__.py new file mode 100644 index 000000000..cfd842dc7 --- /dev/null +++ b/qiita_pet/handlers/rest/__init__.py @@ -0,0 +1,35 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- + +from .study import StudyHandler, StudyCreatorHandler, StudyStatusHandler +from .study_samples import (StudySamplesHandler, StudySamplesInfoHandler, + StudySamplesCategoriesHandler) +from .study_person import StudyPersonHandler +from .study_preparation import (StudyPrepCreatorHandler, + StudyPrepArtifactCreatorHandler) + + +__all__ = ['StudyHandler', 'StudySamplesHandler', 'StudySamplesInfoHandler', + 'StudySamplesCategoriesHandler', 'StudyPersonHandler', + 'StudyCreatorHandler', 'StudyPrepCreatorHandler', + 'StudyPrepArtifactCreatorHandler', 'StudyStatusHandler'] + + +ENDPOINTS = ( + (r"/api/v1/study$", StudyCreatorHandler), + (r"/api/v1/study/([0-9]+)$", StudyHandler), + (r"/api/v1/study/([0-9]+)/samples/categories=([a-zA-Z\-0-9\.:,_]*)", + StudySamplesCategoriesHandler), + (r"/api/v1/study/([0-9]+)/samples", StudySamplesHandler), + (r"/api/v1/study/([0-9]+)/samples/info", StudySamplesInfoHandler), + (r"/api/v1/person(.*)", StudyPersonHandler), + (r"/api/v1/study/([0-9]+)/preparation/([0-9]+)/artifact", + StudyPrepArtifactCreatorHandler), + (r"/api/v1/study/([0-9]+)/preparation(.*)", StudyPrepCreatorHandler), + (r"/api/v1/study/([0-9]+)/status$", StudyStatusHandler) +) diff --git a/qiita_pet/handlers/rest/rest_handler.py b/qiita_pet/handlers/rest/rest_handler.py new file mode 100644 index 000000000..8aa281f41 --- /dev/null +++ b/qiita_pet/handlers/rest/rest_handler.py @@ -0,0 +1,31 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- +from qiita_db.study import Study +from qiita_db.exceptions import QiitaDBUnknownIDError +from qiita_pet.handlers.util import to_int +from qiita_pet.handlers.base_handlers import BaseHandler + + +class RESTHandler(BaseHandler): + def fail(self, msg, status, **kwargs): + out = {'message': msg} + out.update(kwargs) + + self.write(out) + self.set_status(status) + self.finish() + + def safe_get_study(self, study_id): + study_id = to_int(study_id) + s = None + try: + s = Study(study_id) + except QiitaDBUnknownIDError: + self.fail('Study not found', 404) + finally: + return s diff --git a/qiita_pet/handlers/rest/study.py b/qiita_pet/handlers/rest/study.py new file mode 100644 index 000000000..25533ebe3 --- /dev/null +++ b/qiita_pet/handlers/rest/study.py @@ -0,0 +1,154 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- +import warnings + +from tornado.escape import json_decode + +from qiita_db.handlers.oauth2 import authenticate_oauth +from qiita_db.study import StudyPerson, Study +from qiita_db.user import User +from .rest_handler import RESTHandler +from qiita_db.metadata_template.constants import SAMPLE_TEMPLATE_COLUMNS + + +class StudyHandler(RESTHandler): + + @authenticate_oauth + def get(self, study_id): + study = self.safe_get_study(study_id) + if study is None: + return + + info = study.info + pi = info['principal_investigator'] + lp = info['lab_person'] + self.write({'title': study.title, + 'contacts': {'principal_investigator': [ + pi.name, + pi.affiliation, + pi.email], + 'lab_person': [ + lp.name, + lp.affiliation, + lp.email]}, + 'study_abstract': info['study_abstract'], + 'study_description': info['study_description'], + 'study_alias': info['study_alias']}) + self.finish() + + +class StudyCreatorHandler(RESTHandler): + + @authenticate_oauth + def post(self): + try: + payload = json_decode(self.request.body) + except ValueError: + self.fail('Could not parse body', 400) + return + + required = {'title', 'study_abstract', 'study_description', + 'study_alias', 'owner', 'contacts'} + + if not required.issubset(payload): + self.fail('Not all required arguments provided', 400) + return + + title = payload['title'] + study_abstract = payload['study_abstract'] + study_desc = payload['study_description'] + study_alias = payload['study_alias'] + + owner = payload['owner'] + if not User.exists(owner): + self.fail('Unknown user', 403) + return + else: + owner = User(owner) + + contacts = payload['contacts'] + + if Study.exists(title): + self.fail('Study title already exists', 409) + return + + pi_name = contacts['principal_investigator'][0] + pi_aff = contacts['principal_investigator'][1] + if not StudyPerson.exists(pi_name, pi_aff): + self.fail('Unknown principal investigator', 403) + return + else: + pi = StudyPerson.from_name_and_affiliation(pi_name, pi_aff) + + lp_name = contacts['lab_person'][0] + lp_aff = contacts['lab_person'][1] + if not StudyPerson.exists(lp_name, lp_aff): + self.fail('Unknown lab person', 403) + return + else: + lp = StudyPerson.from_name_and_affiliation(lp_name, lp_aff) + + info = {'lab_person_id': lp, + 'principal_investigator_id': pi, + 'study_abstract': study_abstract, + 'study_description': study_desc, + 'study_alias': study_alias, + + # TODO: we believe it is accurate that mixs is false and + # metadata completion is false as these cannot be known + # at study creation here no matter what. + # we do not know what should be done with the timeseries. + 'mixs_compliant': False, + 'metadata_complete': False, + 'timeseries_type_id': 1} + study = Study.create(owner, title, [1], info) + + self.set_status(201) + self.write({'id': study.id}) + self.finish() + + +class StudyStatusHandler(RESTHandler): + @authenticate_oauth + def get(self, study_id): + study = self.safe_get_study(study_id) + if study is None: + return + + public = study.status == 'public' + st = study.sample_template + sample_information = st is not None + if sample_information: + with warnings.catch_warnings(): + try: + st.validate(SAMPLE_TEMPLATE_COLUMNS) + except Warning: + sample_information_warnings = True + else: + sample_information_warnings = False + else: + sample_information_warnings = False + + preparations = [] + for prep in study.prep_templates(): + pid = prep.id + art = prep.artifact is not None + # TODO: unclear how to test for warnings on the preparations as + # it requires knowledge of the preparation type. It is possible + # to tease this out, but it replicates code present in + # PrepTemplate.create, see: + # https://github.com/biocore/qiita/issues/2096 + preparations.append({'id': pid, 'has_artifact': art}) + + self.write({'is_public': public, + 'has_sample_information': sample_information, + 'sample_information_has_warnings': + sample_information_warnings, + 'preparations': preparations}) + self.set_status(200) + self.finish() diff --git a/qiita_pet/handlers/rest/study_person.py b/qiita_pet/handlers/rest/study_person.py new file mode 100644 index 000000000..160e68bfc --- /dev/null +++ b/qiita_pet/handlers/rest/study_person.py @@ -0,0 +1,49 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- + +from qiita_db.handlers.oauth2 import authenticate_oauth +from qiita_db.study import StudyPerson +from qiita_db.exceptions import QiitaDBLookupError +from .rest_handler import RESTHandler + + +class StudyPersonHandler(RESTHandler): + @authenticate_oauth + def get(self, *args, **kwargs): + name = self.get_argument('name') + affiliation = self.get_argument('affiliation') + + try: + p = StudyPerson.from_name_and_affiliation(name, affiliation) + except QiitaDBLookupError: + self.fail('Person not found', 404) + return + + self.write({'address': p.address, 'phone': p.phone, 'email': p.email, + 'id': p.id}) + self.finish() + + @authenticate_oauth + def post(self, *args, **kwargs): + name = self.get_argument('name') + affiliation = self.get_argument('affiliation') + email = self.get_argument('email') + + phone = self.get_argument('phone', None) + address = self.get_argument('address', None) + + if StudyPerson.exists(name, affiliation): + self.fail('Person already exists', 409) + return + + p = StudyPerson.create(name=name, affiliation=affiliation, email=email, + phone=phone, address=address) + + self.set_status(201) + self.write({'id': p.id}) + self.finish() diff --git a/qiita_pet/handlers/rest/study_preparation.py b/qiita_pet/handlers/rest/study_preparation.py new file mode 100644 index 000000000..02d9c5f1b --- /dev/null +++ b/qiita_pet/handlers/rest/study_preparation.py @@ -0,0 +1,88 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- + +import os + +import pandas as pd +from tornado.escape import json_decode + +from qiita_db.util import get_mountpoint +from qiita_db.artifact import Artifact +from qiita_pet.handlers.util import to_int +from qiita_db.exceptions import QiitaDBUnknownIDError, QiitaError +from qiita_db.metadata_template.prep_template import PrepTemplate +from qiita_db.handlers.oauth2 import authenticate_oauth +from .rest_handler import RESTHandler + + +class StudyPrepCreatorHandler(RESTHandler): + # TODO: do something smart about warnings, perhaps this should go in its + # own endpoint i.e. /api/v1/study//preparation/validate + # See also: https://github.com/biocore/qiita/issues/2096 + + @authenticate_oauth + def post(self, study_id, *args, **kwargs): + data_type = self.get_argument('data_type') + investigation_type = self.get_argument('investigation_type', None) + + study_id = self.safe_get_study(study_id) + if study_id is None: + return + + data = pd.DataFrame.from_dict(json_decode(self.request.body), + orient='index') + + try: + p = PrepTemplate.create(data, study_id, data_type, + investigation_type) + except QiitaError as e: + self.fail(e.message, 406) + return + + self.write({'id': p.id}) + self.set_status(201) + self.finish() + + +class StudyPrepArtifactCreatorHandler(RESTHandler): + + @authenticate_oauth + def post(self, study_id, prep_id): + study = self.safe_get_study(study_id) + if study is None: + return + + prep_id = to_int(prep_id) + try: + p = PrepTemplate(prep_id) + except QiitaDBUnknownIDError: + self.fail('Preparation not found', 404) + return + + if p.study_id != study.id: + self.fail('Preparation ID not associated with the study', 409) + return + + artifact_deets = json_decode(self.request.body) + _, upload = get_mountpoint('uploads')[0] + base = os.path.join(upload, study_id) + filepaths = [(os.path.join(base, fp), fp_type) + for fp, fp_type in artifact_deets['filepaths']] + + try: + art = Artifact.create(filepaths, + artifact_deets['artifact_type'], + artifact_deets['artifact_name'], + p) + except QiitaError as e: + self.fail(e.message, 406) + return + + self.write({'id': art.id}) + self.set_status(201) + self.finish() diff --git a/qiita_pet/handlers/rest/study_samples.py b/qiita_pet/handlers/rest/study_samples.py new file mode 100644 index 000000000..a7956924c --- /dev/null +++ b/qiita_pet/handlers/rest/study_samples.py @@ -0,0 +1,83 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- +from tornado.escape import json_encode + +from qiita_db.handlers.oauth2 import authenticate_oauth +from .rest_handler import RESTHandler + + +class StudySamplesHandler(RESTHandler): + + @authenticate_oauth + def get(self, study_id): + study = self.safe_get_study(study_id) + if study is None: + return + + if study.sample_template is None: + samples = [] + else: + samples = list(study.sample_template.keys()) + + self.write(json_encode(samples)) + self.finish() + + +class StudySamplesCategoriesHandler(RESTHandler): + + @authenticate_oauth + def get(self, study_id, categories): + if not categories: + self.fail('No categories specified', 405) + return + + study = self.safe_get_study(study_id) + if study is None: + return + + categories = categories.split(',') + + if study.sample_template is None: + self.fail('Study does not have sample information', 404) + return + + available_categories = set(study.sample_template.categories()) + not_found = set(categories) - available_categories + if not_found: + self.fail('Category not found', 404, + categories_not_found=sorted(not_found)) + return + + blob = {'header': categories, + 'samples': {}} + df = study.sample_template.to_dataframe() + for idx, row in df[categories].iterrows(): + blob['samples'][idx] = list(row) + + self.write(json_encode(blob)) + self.finish() + + +class StudySamplesInfoHandler(RESTHandler): + + @authenticate_oauth + def get(self, study_id): + study = self.safe_get_study(study_id) + if study is None: + return + + st = study.sample_template + if st is None: + info = {'number-of-samples': 0, + 'categories': []} + else: + info = {'number-of-samples': len(st), + 'categories': st.categories()} + + self.write(json_encode(info)) + self.finish() diff --git a/qiita_pet/support_files/doc/source/dev/index.rst b/qiita_pet/support_files/doc/source/dev/index.rst index fffea5391..07f043f41 100644 --- a/qiita_pet/support_files/doc/source/dev/index.rst +++ b/qiita_pet/support_files/doc/source/dev/index.rst @@ -7,6 +7,7 @@ The following is a full list of the available developer tutorials :maxdepth: 2 plugins + rest To request documentation on any developer use-cases not addressed here, please add an issue `here `__. diff --git a/qiita_pet/support_files/doc/source/dev/rest.rst b/qiita_pet/support_files/doc/source/dev/rest.rst new file mode 100644 index 000000000..f28f6991e --- /dev/null +++ b/qiita_pet/support_files/doc/source/dev/rest.rst @@ -0,0 +1,31 @@ +.. _plugins: + +.. index :: rest + +Qiita REST API +============== + +The Qiita REST API is currently only for internal use and is composed of the +following endpoints: + ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ +| Action | URI | Description | ++========+===========================================================+==========================================================================================================================================================+ +|GET | ``/api/v1/study/`` | Get study details (title, contacts, abstract, description and alias). | ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ +|GET | ``/api/v1/study//samples`` | Get samples associated with a study and the available metadata headers for these samples. | ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ +|GET | ``/api/v1/study//samples?categories=foo,bar`` | Get metadata categories foo and bar for all samples in the study. | ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ +|GET | ``/api/v1/study//status`` | The status of a study (whether or not the study: is public, has sample information, sample information has warnings and a list of existing preparations. | ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ +|GET | ``/api/v1/person?name=foo&affiliation=bar`` | See if a person exists. | ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ +|POST | ``/api/v1/study`` | Create a study (mirrors study creation on qiita UI with minimal requirements). | ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ +|POST | ``/api/v1/person?name=foo&affiliation=bar&email=address`` | Create a study person (ie lab person or PI). | ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ +|POST | ``/api/v1/study//preparation`` | Associate a prep with a study. | ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ +|POST | ``/api/v1/study//preparation//artifact`` | Associate filepaths to a preparation, assuming this filepaths are present in the uploads folder. | ++--------+-----------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+ diff --git a/qiita_pet/test/rest/__init__.py b/qiita_pet/test/rest/__init__.py new file mode 100644 index 000000000..e0aff71d9 --- /dev/null +++ b/qiita_pet/test/rest/__init__.py @@ -0,0 +1,7 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- diff --git a/qiita_pet/test/rest/test_base.py b/qiita_pet/test/rest/test_base.py new file mode 100644 index 000000000..0ccb3d5da --- /dev/null +++ b/qiita_pet/test/rest/test_base.py @@ -0,0 +1,22 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- +from moi import r_client + +from qiita_pet.test.tornado_test_base import TestHandlerBase + + +class RESTHandlerTestCase(TestHandlerBase): + def setUp(self): + self.client_token = 'SOMEAUTHTESTINGTOKENHERE2122' + r_client.hset(self.client_token, 'timestamp', '12/12/12 12:12:00') + r_client.hset(self.client_token, 'client_id', 'test123123123') + r_client.hset(self.client_token, 'grant_type', 'client') + r_client.expire(self.client_token, 5) + + self.headers = {'Authorization': 'Bearer ' + self.client_token} + super(RESTHandlerTestCase, self).setUp() diff --git a/qiita_pet/test/rest/test_study.py b/qiita_pet/test/rest/test_study.py new file mode 100644 index 000000000..9474fd375 --- /dev/null +++ b/qiita_pet/test/rest/test_study.py @@ -0,0 +1,163 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- + +from unittest import main + +from tornado.escape import json_decode + +from qiita_db.study import Study +from qiita_pet.test.rest.test_base import RESTHandlerTestCase + + +class StudyHandlerTests(RESTHandlerTestCase): + def test_get_valid(self): + exp = {u'title': u'Identification of the Microbiomes for Cannabis ' + u'Soils', + u'contacts': {'principal_investigator': [u'PIDude', + u'Wash U', + u'PI_dude@foo.bar'], + 'lab_person': [u'LabDude', + u'knight lab', + u'lab_dude@foo.bar']}, + u'study_abstract': + (u'This is a preliminary study to examine the ' + u'microbiota associated with the Cannabis plant. ' + u'Soils samples from the bulk soil, soil ' + u'associated with the roots, and the rhizosphere ' + u'were extracted and the DNA sequenced. Roots ' + u'from three independent plants of different ' + u'strains were examined. These roots were ' + u'obtained November 11, 2011 from plants that ' + u'had been harvested in the summer. Future ' + u'studies will attempt to analyze the soils and ' + u'rhizospheres from the same location at ' + u'different time points in the plant lifecycle.'), + u'study_description': (u'Analysis of the Cannabis Plant ' + u'Microbiome'), + u'study_alias': 'Cannabis Soils'} + + response = self.get('/api/v1/study/1', headers=self.headers) + self.assertEqual(response.code, 200) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_invalid(self): + response = self.get('/api/v1/study/0', headers=self.headers) + self.assertEqual(response.code, 404) + self.assertEqual(json_decode(response.body), + {'message': 'Study not found'}) + + def test_get_invalid_negative(self): + response = self.get('/api/v1/study/-1', headers=self.headers) + self.assertEqual(response.code, 404) + # not asserting the body content as this is not a valid URI according + # to the regex associating the handler to the webserver + + def test_get_invalid_namespace(self): + response = self.get('/api/v1/study/1.11111', headers=self.headers) + self.assertEqual(response.code, 404) + # not asserting the body content as this is not a valid URI according + # to the regex associating the handler to the webserver + + +class StudyCreatorTests(RESTHandlerTestCase): + def test_post_malformed_study(self): + response = self.post('/api/v1/study', data={'foo': 'bar'}, + headers=self.headers, asjson=True) + self.assertEqual(response.code, 400) + + def test_post_already_exists(self): + payload = {'title': 'Identification of the Microbiomes for Cannabis ' + 'Soils', + 'study_abstract': 'stuff', + 'study_description': 'asdasd', + 'owner': 'admin@foo.bar', + 'study_alias': 'blah', + 'contacts': {'principal_investigator': [u'PIDude', + u'PI_dude@foo.bar'], + 'lab_person': [u'LabDude', + u'lab_dude@foo.bar']}} + response = self.post('/api/v1/study', data=payload, asjson=True, + headers=self.headers) + self.assertEqual(response.code, 409) + obs = json_decode(response.body) + self.assertEqual(obs, + {'message': 'Study title already exists'}) + + def test_post_valid(self): + payload = {'title': 'foo', + 'study_abstract': 'stuff', + 'study_description': 'asdasd', + 'owner': 'admin@foo.bar', + 'study_alias': 'blah', + 'contacts': {'principal_investigator': [u'PIDude', + u'Wash U'], + 'lab_person': [u'LabDude', + u'knight lab']}} + response = self.post('/api/v1/study', data=payload, + headers=self.headers, asjson=True) + self.assertEqual(response.code, 201) + study_id = json_decode(response.body)['id'] + study = Study(int(study_id)) + + self.assertEqual(study.title, payload['title']) + self.assertEqual(study.info['study_abstract'], + payload['study_abstract']) + self.assertEqual(study.info['study_description'], + payload['study_description']) + self.assertEqual(study.info['study_alias'], payload['study_alias']) + self.assertEqual(study.owner.email, payload['owner']) + self.assertEqual(study.info['principal_investigator'].name, + payload['contacts']['principal_investigator'][0]) + self.assertEqual(study.info['principal_investigator'].affiliation, + payload['contacts']['principal_investigator'][1]) + self.assertEqual(study.info['lab_person'].name, + payload['contacts']['lab_person'][0]) + self.assertEqual(study.info['lab_person'].affiliation, + payload['contacts']['lab_person'][1]) + + def test_post_invalid_user(self): + payload = {'title': 'foo', + 'study_abstract': 'stuff', + 'study_description': 'asdasd', + 'owner': 'doesnotexist@foo.bar', + 'study_alias': 'blah', + 'contacts': {'principal_investigator': [u'PIDude', + u'Wash U'], + 'lab_person': [u'LabDude', + u'knight lab']}} + response = self.post('/api/v1/study', data=payload, + headers=self.headers, asjson=True) + self.assertEqual(response.code, 403) + obs = json_decode(response.body) + self.assertEqual(obs, {'message': 'Unknown user'}) + + +class StudyStatusHandlerTests(RESTHandlerTestCase): + def test_get_no_study(self): + response = self.get('/api/v1/study/0/status', headers=self.headers) + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + exp = {'message': 'Study not found'} + self.assertEqual(obs, exp) + + def test_get_valid(self): + response = self.get('/api/v1/study/1/status', headers=self.headers) + self.assertEqual(response.code, 200) + exp = {'is_public': False, + 'has_sample_information': True, + 'sample_information_has_warnings': False, + 'preparations': [{'id': 1, 'has_artifact': True}, + {'id': 2, 'has_artifact': True}] + } + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + +if __name__ == '__main__': + main() diff --git a/qiita_pet/test/rest/test_study_person.py b/qiita_pet/test/rest/test_study_person.py new file mode 100644 index 000000000..42e1d4b96 --- /dev/null +++ b/qiita_pet/test/rest/test_study_person.py @@ -0,0 +1,88 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- + +from unittest import main + +from tornado.escape import json_decode + +from qiita_db.study import StudyPerson +from qiita_pet.test.rest.test_base import RESTHandlerTestCase + + +class StudyPersonHandlerTests(RESTHandlerTestCase): + def test_exist(self): + exp = {'email': 'lab_dude@foo.bar', 'phone': '121-222-3333', + 'address': '123 lab street', 'id': 1} + response = self.get('/api/v1/person?name=LabDude&' + 'affiliation=knight%20lab', headers=self.headers) + self.assertEqual(response.code, 200) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_does_not_exist(self): + exp = {'message': 'Person not found'} + response = self.get('/api/v1/person?name=Boaty%20McBoatFace&' + 'affiliation=UCSD', headers=self.headers) + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_does_not_exist_affiliation(self): + exp = {'message': 'Person not found'} + response = self.get('/api/v1/person?name=LabDude%20&affiliation=UCSD', + headers=self.headers) + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_invalid_query_string(self): + response = self.get('/api/v1/person?name=LabDude', + headers=self.headers) + self.assertEqual(response.code, 400) + + def test_get_valid_extra_arguments(self): + exp = {'email': 'lab_dude@foo.bar', 'phone': '121-222-3333', + 'address': '123 lab street', 'id': 1} + response = self.get('/api/v1/person?name=LabDude&' + 'affiliation=knight%20lab&foo=bar', + headers=self.headers) + self.assertEqual(response.code, 200) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_post_new_person(self): + body = {'name': 'Boaty McBoatFace', 'affiliation': 'UCSD', + 'email': 'boat@ucsd.edu', 'phone': '720-876-5309'} + + response = self.post('/api/v1/person', data=body, headers=self.headers) + self.assertEqual(response.code, 201) + obs = json_decode(response.body) + exp = StudyPerson.from_name_and_affiliation(body['name'], + body['affiliation']).id + self.assertEqual(exp, obs['id']) + + def test_post_existing(self): + body = {'name': 'LabDude', 'affiliation': 'knight lab', + 'email': 'lab_dude@foo.bar', 'phone': '121-222-3333'} + + response = self.post('/api/v1/person', data=body, headers=self.headers) + self.assertEqual(response.code, 409) + obs = json_decode(response.body) + exp = {'message': 'Person already exists'} + self.assertEqual(exp, obs) + + def test_post_incomplete_details(self): + body = {'affiliation': 'knight lab', + 'email': 'lab_dude@foo.bar', 'phone': '121-222-3333'} + + response = self.post('/api/v1/person', data=body, headers=self.headers) + self.assertEqual(response.code, 400) + + +if __name__ == '__main__': + main() diff --git a/qiita_pet/test/rest/test_study_preparation.py b/qiita_pet/test/rest/test_study_preparation.py new file mode 100644 index 000000000..fbbda63f5 --- /dev/null +++ b/qiita_pet/test/rest/test_study_preparation.py @@ -0,0 +1,181 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- + +from unittest import main +from StringIO import StringIO +import os + +import pandas as pd + +from tornado.escape import json_decode + +from qiita_db.metadata_template.util import load_template_to_dataframe +from qiita_db.metadata_template.prep_template import PrepTemplate +from qiita_pet.test.rest.test_base import RESTHandlerTestCase +from qiita_db.util import get_mountpoint + + +class StudyPrepCreatorTests(RESTHandlerTestCase): + def test_post_non_existant_study(self): + # study id that does not exist + prep = StringIO(EXP_PREP_TEMPLATE.format(0)) + prep_table = load_template_to_dataframe(prep) + + response = self.post('/api/v1/study/0/preparation?' + '&data_type=16S', + data=prep_table.T.to_dict(), + headers=self.headers, asjson=True) + self.assertEqual(response.code, 404) + + def test_post_non_matching_identifiers(self): + prep = StringIO(EXP_PREP_TEMPLATE.format(100)) + prep_table = load_template_to_dataframe(prep) + + response = self.post('/api/v1/study/1/preparation?' + 'data_type=16S', + data=prep_table.T.to_dict(), + headers=self.headers, asjson=True) + self.assertEqual(response.code, 406) + obs = json_decode(response.body) + self.assertEqual(list(obs.keys()), ['message']) + self.assertGreater(len(obs['message']), 0) + + def test_post_valid_study(self): + prep = StringIO(EXP_PREP_TEMPLATE.format(1)) + prep_table = load_template_to_dataframe(prep) + + response = self.post('/api/v1/study/1/preparation?data_type=16S', + data=prep_table.T.to_dict(), + headers=self.headers, asjson=True) + self.assertEqual(response.code, 201) + exp = json_decode(response.body) + exp_prep = PrepTemplate(exp['id']).to_dataframe() + + prep_table.index.name = 'sample_id' + + # sort columns to be comparable + prep_table = prep_table[sorted(prep_table.columns.tolist())] + exp_prep = exp_prep[sorted(exp_prep.columns.tolist())] + exp_prep.drop('qiita_prep_id', axis=1, inplace=True) + + pd.util.testing.assert_frame_equal(prep_table, exp_prep) + + +class StudyPrepArtifactCreatorTests(RESTHandlerTestCase): + def test_post_non_existant_study(self): + uri = '/api/v1/study/0/preparation/0/artifact' + body = {'artifact_type': 'foo', 'filepaths': [['foo.txt', 1], + ['bar.txt', 1]], + 'artifact_name': 'a name is a name'} + + response = self.post(uri, data=body, headers=self.headers, asjson=True) + exp = {'message': 'Study not found'} + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_post_non_existant_prep(self): + uri = '/api/v1/study/1/preparation/1337/artifact' + body = {'artifact_type': 'foo', 'filepaths': [['foo.txt', 1], + ['bar.txt', 1]], + 'artifact_name': 'a name is a name'} + + response = self.post(uri, data=body, headers=self.headers, asjson=True) + exp = {'message': 'Preparation not found'} + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_post_unknown_artifact_type(self): + uri = '/api/v1/study/1/preparation/1/artifact' + body = {'artifact_type': 'foo', 'filepaths': [['foo.txt', 1], + ['bar.txt', 1]], + 'artifact_name': 'a name is a name'} + + response = self.post(uri, data=body, headers=self.headers, asjson=True) + self.assertEqual(response.code, 406) + obs = json_decode(response.body) + self.assertEqual(list(obs.keys()), ['message']) + self.assertGreater(len(obs['message']), 0) + + def test_post_unknown_filepath_type_id(self): + uri = '/api/v1/study/1/preparation/1/artifact' + body = {'artifact_type': 'foo', 'filepaths': [['foo.txt', 123123], + ['bar.txt', 1]], + 'artifact_name': 'a name is a name'} + + response = self.post(uri, data=body, headers=self.headers, asjson=True) + self.assertEqual(response.code, 406) + obs = json_decode(response.body) + self.assertEqual(list(obs.keys()), ['message']) + self.assertGreater(len(obs['message']), 0) + + def test_post_files_notfound(self): + uri = '/api/v1/study/1/preparation/1/artifact' + body = {'artifact_type': 'foo', 'filepaths': [['foo.txt', 1], + ['bar.txt', 1]], + 'artifact_name': 'a name is a name'} + + response = self.post(uri, data=body, headers=self.headers, asjson=True) + self.assertEqual(response.code, 406) + obs = json_decode(response.body) + self.assertEqual(list(obs.keys()), ['message']) + self.assertGreater(len(obs['message']), 0) + + def test_post_valid(self): + dontcare, uploads_dir = get_mountpoint('uploads')[0] + foo_fp = os.path.join(uploads_dir, '1', 'foo.txt') + bar_fp = os.path.join(uploads_dir, '1', 'bar.txt') + with open(foo_fp, 'w') as fp: + fp.write("@x\nATGC\n+\nHHHH\n") + with open(bar_fp, 'w') as fp: + fp.write("@x\nATGC\n+\nHHHH\n") + + prep = StringIO(EXP_PREP_TEMPLATE.format(1)) + prep_table = load_template_to_dataframe(prep) + + response = self.post('/api/v1/study/1/preparation?data_type=16S', + data=prep_table.T.to_dict(), + headers=self.headers, asjson=True) + prepid = json_decode(response.body)['id'] + + uri = '/api/v1/study/1/preparation/%d/artifact' % prepid + # 1 -> fwd or rev sequences in fastq + # 3 -> barcodes + body = {'artifact_type': 'FASTQ', 'filepaths': [['foo.txt', 1], + ['bar.txt', + 'raw_barcodes']], + 'artifact_name': 'a name is a name'} + + response = self.post(uri, data=body, headers=self.headers, asjson=True) + self.assertEqual(response.code, 201) + obs = json_decode(response.body)['id'] + + prep_instance = PrepTemplate(prepid) + exp = prep_instance.artifact.id + self.assertEqual(obs, exp) + + +EXP_PREP_TEMPLATE = ( + 'sample_name\tbarcode\tcenter_name\tcenter_project_name\t' + 'ebi_submission_accession\temp_status\texperiment_design_description\t' + 'instrument_model\tlibrary_construction_protocol\tplatform\tprimer\t' + 'bar\trun_prefix\tstr_column\n' + '{0}.SKB7.640196\tCCTCTGAGAGCT\tANL\tTest Project\t\tEMP\tBBBB\t' + 'Illumina MiSeq\tAAAA\tILLUMINA\tGTGCCAGCMGCCGCGGTAA\tfoo\t' + 's_G1_L002_sequences\tValue for sample 3\n' + '{0}.SKB8.640193\tGTCCGCAAGTTA\tANL\tTest Project\t\tEMP\tBBBB\t' + 'Illumina MiSeq\tAAAA\tILLUMINA\tGTGCCAGCMGCCGCGGTAA\tfoo\t' + 's_G1_L001_sequences\tValue for sample 1\n' + '{0}.SKD8.640184\tCGTAGAGCTCTC\tANL\tTest Project\t\tEMP\tBBBB\t' + 'Illumina MiSeq\tAAAA\tILLUMINA\tGTGCCAGCMGCCGCGGTAA\tfoo\t' + 's_G1_L001_sequences\tValue for sample 2\n') + + +if __name__ == '__main__': + main() diff --git a/qiita_pet/test/rest/test_study_samples.py b/qiita_pet/test/rest/test_study_samples.py new file mode 100644 index 000000000..de23d767d --- /dev/null +++ b/qiita_pet/test/rest/test_study_samples.py @@ -0,0 +1,218 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2014--, The Qiita Development Team. +# +# Distributed under the terms of the BSD 3-clause License. +# +# The full license is in the file LICENSE, distributed with this software. +# ----------------------------------------------------------------------------- + +from unittest import main +from datetime import datetime + +from tornado.escape import json_decode + +from qiita_db.study import Study, StudyPerson +from qiita_db.user import User +from qiita_pet.test.rest.test_base import RESTHandlerTestCase + + +class StudySamplesHandlerTests(RESTHandlerTestCase): + def test_get_valid(self): + exp = sorted(['1.SKB2.640194', '1.SKM4.640180', '1.SKB3.640195', + '1.SKB6.640176', '1.SKD6.640190', '1.SKM6.640187', + '1.SKD9.640182', '1.SKM8.640201', '1.SKM2.640199', + '1.SKD2.640178', '1.SKB7.640196', '1.SKD4.640185', + '1.SKB8.640193', '1.SKM3.640197', '1.SKD5.640186', + '1.SKB1.640202', '1.SKM1.640183', '1.SKD1.640179', + '1.SKD3.640198', '1.SKB5.640181', '1.SKB4.640189', + '1.SKB9.640200', '1.SKM9.640192', '1.SKD8.640184', + '1.SKM5.640177', '1.SKM7.640188', '1.SKD7.640191']) + response = self.get('/api/v1/study/1/samples', headers=self.headers) + self.assertEqual(response.code, 200) + obs = json_decode(response.body) + self.assertEqual(sorted(obs), exp) + + def test_get_invalid_no_study(self): + exp = {'message': 'Study not found'} + response = self.get('/api/v1/study/0/samples', headers=self.headers) + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_study_no_samples(self): + info = { + "timeseries_type_id": 1, + "metadata_complete": True, + "mixs_compliant": True, + "number_samples_collected": 25, + "number_samples_promised": 28, + "study_alias": "FCM", + "study_description": "DESC", + "study_abstract": "ABS", + "principal_investigator_id": StudyPerson(3), + 'first_contact': datetime(2015, 5, 19, 16, 10), + 'most_recent_contact': datetime(2015, 5, 19, 16, 11), + } + + new_study = Study.create(User('test@foo.bar'), + "Some New Study for test", [1], + info) + + exp = [] + response = self.get('/api/v1/study/%d/samples' % new_study.id, + headers=self.headers) + self.assertEqual(response.code, 200) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + +class StudySamplesInfoHandlerTests(RESTHandlerTestCase): + def test_get_valid(self): + exp = {'number-of-samples': 27, + 'categories': ['season_environment', + 'assigned_from_geo', 'texture', 'taxon_id', + 'depth', 'host_taxid', 'common_name', + 'water_content_soil', 'elevation', 'temp', + 'tot_nitro', 'samp_salinity', 'altitude', + 'env_biome', 'country', 'ph', 'anonymized_name', + 'tot_org_carb', 'description_duplicate', + 'env_feature', 'physical_specimen_location', + 'physical_specimen_remaining', 'dna_extracted', + 'sample_type', 'collection_timestamp', + 'host_subject_id', 'description', + 'latitude', 'longitude', 'scientific_name']} + response = self.get('/api/v1/study/1/samples/info', + headers=self.headers) + self.assertEqual(response.code, 200) + obs = json_decode(response.body) + self.assertEqual(obs.keys(), exp.keys()) + self.assertEqual(obs['number-of-samples'], exp['number-of-samples']) + self.assertItemsEqual(obs['categories'], exp['categories']) + + def test_get_study_does_not_exist(self): + exp = {'message': 'Study not found'} + response = self.get('/api/v1/study/0/samples/info', + headers=self.headers) + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_no_samples(self): + # /api/v1/study/%d/samples/info -> {'number-of-samples':, + # 'categories': []} + info = { + "timeseries_type_id": 1, + "metadata_complete": True, + "mixs_compliant": True, + "number_samples_collected": 25, + "number_samples_promised": 28, + "study_alias": "FCM", + "study_description": "DESC", + "study_abstract": "ABS", + "principal_investigator_id": StudyPerson(3), + 'first_contact': datetime(2015, 5, 19, 16, 10), + 'most_recent_contact': datetime(2015, 5, 19, 16, 11), + } + + new_study = Study.create(User('test@foo.bar'), + "Some New Study for test", [1], + info) + exp = {'number-of-samples': 0, 'categories': []} + response = self.get('/api/v1/study/%d/samples/info' % new_study.id, + headers=self.headers) + self.assertEqual(response.code, 200) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + +class StudySamplesCategoriesHandlerTests(RESTHandlerTestCase): + def test_get_valid_two_arg(self): + df = Study(1).sample_template.to_dataframe() + df = df[['ph', 'country']] + df = {idx: [row['ph'], row['country']] for idx, row in df.iterrows()} + exp = {'header': ['ph', 'country'], + 'samples': df} + + response = self.get('/api/v1/study/1/samples/categories=ph,country', + headers=self.headers) + self.assertEqual(response.code, 200) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_valid_one_arg(self): + df = Study(1).sample_template.to_dataframe() + df = df[['ph', 'country']] + df = {idx: [row['country']] for idx, row in df.iterrows()} + exp = {'header': ['country'], 'samples': df} + + response = self.get('/api/v1/study/1/samples/categories=country', + headers=self.headers) + self.assertEqual(response.code, 200) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_valid_two_arg_one_bad(self): + exp = {'message': 'Category not found', + 'categories_not_found': ['foo']} + response = self.get('/api/v1/study/1/samples/categories=country,foo', + headers=self.headers) + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_study_does_not_exist(self): + exp = {'message': 'Study not found'} + response = self.get('/api/v1/study/0/samples/categories=foo', + headers=self.headers) + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_bad_category(self): + exp = {'message': 'Category not found', + 'categories_not_found': ['foo']} + response = self.get('/api/v1/study/1/samples/categories=foo', + headers=self.headers) + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_no_category(self): + exp = {'message': 'No categories specified'} + response = self.get('/api/v1/study/1/samples/categories=', + headers=self.headers) + self.assertEqual(response.code, 405) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + def test_get_no_samples(self): + # /api/v1/study/%d/samples/info -> {'number-of-samples':, + # 'categories': []} + info = { + "timeseries_type_id": 1, + "metadata_complete": True, + "mixs_compliant": True, + "number_samples_collected": 25, + "number_samples_promised": 28, + "study_alias": "FCM", + "study_description": "DESC", + "study_abstract": "ABS", + "principal_investigator_id": StudyPerson(3), + 'first_contact': datetime(2015, 5, 19, 16, 10), + 'most_recent_contact': datetime(2015, 5, 19, 16, 11), + } + + new_study = Study.create(User('test@foo.bar'), + "Some New Study for test", [1], + info) + + exp = {'message': 'Study does not have sample information'} + response = self.get('/api/v1/study/%d/samples/categories=foo' % + new_study.id, headers=self.headers) + self.assertEqual(response.code, 404) + obs = json_decode(response.body) + self.assertEqual(obs, exp) + + +if __name__ == '__main__': + main() diff --git a/qiita_pet/test/tornado_test_base.py b/qiita_pet/test/tornado_test_base.py index 13abf52c0..a1360cd56 100644 --- a/qiita_pet/test/tornado_test_base.py +++ b/qiita_pet/test/tornado_test_base.py @@ -5,6 +5,7 @@ from urllib.parse import urlencode from tornado.testing import AsyncHTTPTestCase +from tornado.escape import json_encode from qiita_pet.webserver import Application from qiita_pet.handlers.base_handlers import BaseHandler from qiita_db.environment_manager import clean_test_environment @@ -35,9 +36,11 @@ def get(self, url, data=None, headers=None, doseq=True): url += '?%s' % data return self._fetch(url, 'GET', headers=headers) - def post(self, url, data, headers=None, doseq=True): + def post(self, url, data, headers=None, doseq=True, asjson=False): if data is not None: - if isinstance(data, dict): + if asjson: + data = json_encode(data) + elif isinstance(data, dict): data = urlencode(data, doseq=doseq) return self._fetch(url, 'POST', data, headers) diff --git a/qiita_pet/webserver.py b/qiita_pet/webserver.py index 42952d478..75bf37e61 100644 --- a/qiita_pet/webserver.py +++ b/qiita_pet/webserver.py @@ -57,6 +57,7 @@ ReloadPluginAPItestHandler) from qiita_pet import uimodules from qiita_db.util import get_mountpoint +from qiita_pet.handlers.rest import ENDPOINTS as REST_ENDPOINTS if qiita_config.portal == "QIITA": from qiita_pet.handlers.portal import ( StudyPortalHandler, StudyPortalAJAXHandler) @@ -166,6 +167,10 @@ def __init__(self): (r"/qiita_db/plugins/(.*)/(.*)/commands/", CommandListHandler), (r"/qiita_db/plugins/(.*)/(.*)/", PluginHandler) ] + + # rest endpoints + handlers.extend(REST_ENDPOINTS) + if qiita_config.portal == "QIITA": # Add portals editing pages only on main portal portals = [