""" This is the central interface to the OCL API. """ import os import logging import requests import simplejson as json from django.conf import settings from .search import OclSearch from .constants import OclConstants SESSION_TOKEN_KEY = 'API_USER_TOKEN' class OclApi(object): """ Interface to the OCL API backend. Handles all the authentication and formating. Also contain helper and utility functions. :logging: This class outputs debug level information to the "oclapi" logger. """ logger = logging.getLogger('oclapi.request') def __init__(self, request=None, debug=False, admin=False, facets=False): """ :param request: gives API access to the current active session, to get Authorization etc. :param admin: optional, if set to True, access API as admin user. Needed for create_user. :param facets: optional, if set to True, API returns faceted search information instead of clean JSON results. Note that faceted results are only applicable on certain list requests, and this argument is ignored otherwise. """ self.status_code = None self.debug = debug self.host = settings.API_HOST # backend location self.headers = {'Content-Type': 'application/json'} # The admin api key should only be used for admin functions (duh) self.admin_api_key = settings.API_TOKEN self.url = None self.api_key = None self.include_facets = facets if admin: self.headers['Authorization'] = 'Token %s' % self.admin_api_key else: if request: self.api_key = request.session.get(SESSION_TOKEN_KEY, None) if self.api_key: self.headers['Authorization'] = 'Token %s' % self.api_key def debug_result(self, results): """ Some serious debug output. """ self.logger.debug('API %s' % (results.request.path_url)) self.logger.debug('%s RESULT: %s' % ( results.request.method, results.status_code)) if results.status_code == requests.codes.server_error: self.logger.error(results.content) elif len(results.content) > 0: try: self.logger.debug('%s JSON: %s' % (results.request.method, json.dumps(results.json(), sort_keys=True, indent=4, separators=(',', ': ')))) except json.JSONDecodeError: self.logger.error('%s %s JSON: Error decoding it: %s' % (results.request.method, results.request.path_url, results.content[:40])) else: self.logger.debug('%s no content.' % results.request.method) @property def include_facets(self): """ Return whether 'includeFacets' is set in the request headers """ if 'includeFacets' in self.headers: return True else: return False @include_facets.setter def include_facets(self, include_facets_bool): """ Set whether 'includeFacets' is included in the request header """ if include_facets_bool: self.headers['includeFacets'] = 'true' elif 'includeFacets' in self.headers: del self.headers['includeFacets'] def post(self, type_name, *args, **kwargs): """ Issue POST request to API. :param type_name: is a string specifying the type of the object according to the API. :param *args: The rest of the positional arguments will be appended to the post URL :param *kwargs: all the keyword arguments will become post data. :returns: response object from requests. """ url = '%s/%s/' % (self.host, type_name) if len(args) > 0: url = url + '/'.join(args) + '/' if self.debug: self.logger.debug('POST %s %s %s' % (url, json.dumps(kwargs), self.headers)) results = requests.post(url, data=json.dumps(kwargs), headers=self.headers) self.status_code = results.status_code if self.debug: self.debug_result(results) return results def delete(self, *args, **kwargs): """ Issue delete request to API. """ url = '%s/' % (self.host) if len(args) > 0: url = url + '/'.join(args) + '/' if self.debug: self.logger.debug('DELETE %s %s %s' % (url, json.dumps(kwargs), self.headers)) results = requests.delete(url, data=json.dumps(kwargs), headers=self.headers) self.status_code = results.status_code return results def put(self, type_name, *args, **kwargs): """ Issue delete request to API. :param type_name: is a string specifying the type of the object according to the API. """ url = '%s/%s/' % (self.host, type_name) if len(args) > 0: url = url + '/'.join(args) + '/' if self.debug: self.logger.debug('PUT %s %s %s' % (url, json.dumps(kwargs), self.headers)) params = kwargs.get('params') results = requests.put(url, data=json.dumps(kwargs), headers=self.headers, params=params) self.status_code = results.status_code if self.debug: self.debug_result(results) return results def head(self, *args, **kwargs): """ Issue HEAD request to API. :param *args: All positional arguments are appended to the request URL. :param **kwargs: These are not used at the moment, since this is a get request TODO :returns: requests.response object. """ self.url = '%s/' % (self.host) if len(args) > 0: self.url = self.url + '/'.join(args) + '/' if self.debug: self.logger.debug('HEAD %s %s %s' % (self.url, json.dumps(kwargs), self.headers)) # look for optional keyword argument params for constructing URL param # i.e. ?f1=v1&f2=v2 params = kwargs.get('params') results = requests.head(self.url, params=params, headers=self.headers) self.status_code = results.status_code if self.debug: self.debug_result(results) return results def get(self, *args, **kwargs): """ Issue get request to API. :param *args: All positional arguments are appended to the request URL. Note: To pass query parameters to the GET function, use a params={k:v} keyword argument. :param **kwargs: These are not used at the moment, since this is a get request TODO :returns: requests.response object. """ # Build the URL self.url = '%s/' % (self.host) if len(args) > 0: self.url = self.url + '/'.join(args) if self.url[-1] != '/': self.url += '/' # Look for optional keyword argument params for constructing URL param e.g. ?f1=v1&f2=v2 params = kwargs.get('params') if self.debug: self.logger.debug('GET %s %s %s' % (self.url, params, self.headers)) results = requests.get(self.url, params=params, headers=self.headers) self.status_code = results.status_code if self.debug: self.debug_result(results) return results # TODO: Retire get_json? def get_json(self, *args): """ Smarter GET request when you really want a json object back. Note: This is experimental -- not sure if this is the right abstraction. :param *args: All positional arguments are appended to the request URL. :returns: json string or None if error. :exception: Will raise exception if response status code is not 200. """ results = self.get(*args) if results.status_code != requests.codes.ok: results.raise_for_status() if len(results.content) > 0: return results.json() else: return None # TODO: Retire get_by_url? def get_by_url(self, url, **kwargs): """ Issue get request to API. :param url: is a string specifying the request url. Useful for urls contained in OCL response data like members_url. """ url = '%s/%s' % (self.host, url) if self.debug: self.logger.debug('GET %s %s %s' % (url, json.dumps(kwargs), self.headers)) results = requests.get(url, data=json.dumps(kwargs), headers=self.headers) return results def save_auth_token(self, request, json_data): """ Save API user token into session table for online use. :param request: is the django http request :param api_json_data: contains the backend auth token. """ request.session[SESSION_TOKEN_KEY] = json_data['token'] def create_user(self, data): """ Create a user in the system. This call is a bit special because users need to be created using admin credentials. :param data: is a dictionary of all the data fields. :returns: requests.reponse object """ result = self.post('users', **data) return result def delete_user(self, username): """ Delete a user in the system, actually just deactivates her. delete users needs admin credentials. :param username: is a string specifying the username. :returns: ?? """ result = self.delete('users', username) return result def reactivate_user(self, username): """ Delete a user in the system, actually just deactivates her. delete users needs admin credentials. :param username: is a string specifying the username. :returns: ?? """ result = self.put('users/%s/reactivate/' % username) return result def get_user_auth(self, username, password, hashed=True): """ Get the user AUTH token for the specified user. :param username: is a string containing the user name. :returns: ?? """ if hashed: result = self.post('users', 'login', username=username, hashed_password=password) else: result = self.post('users', 'login', username=username, password=password) return result def sync_password(self, user): """ sync password with backend """ result = self.post('users/%s' % user.username, hashed_password=user.password) return result def extract_names(self, names): if names is None: return [] return names def extract_descriptions(self, descriptions): if descriptions is None: return None if len(descriptions) is 1 and not descriptions[0]['description']: return None return descriptions def create_concept(self, source_owner_type, source_owner_id, source_id, base_data, names=[], descriptions=[], extras=None): """ Create a concept. :param source_owner_type: 'orgs' or 'users' :param source_owner_id: ID of org/user owner :param source_id: is the ID of the owner source :param base_data: is a dictionary of all the data fields :param names: is a list of dictionary of name fields, optional. :param descriptions: is a list of dictionary of name fields, optional. :param extras: is a dictionary of name fields, optional. :returns: POST result from requests package. """ data = {} data.update(base_data) data['names'] = self.extract_names(names) data['descriptions'] = self.extract_descriptions(descriptions) if extras: data['extras'] = extras result = self.post( source_owner_type, source_owner_id, 'sources', source_id, 'concepts', **data) return result def update_concept(self, source_owner_type, source_owner_id, source_id, concept_id, base_data, names=[], descriptions=[], extras=[]): """ Update a concept. NOTE: currently add by org+source, but there are other options... TODO :param source_owner_type: 'orgs' or 'users' :param source_owner_id: ID of org/user owner :param source_id: is the ID of the owner source :param concept_id: is the ID of the owner source :param base_data: is a dictionary of all the data fields :param names: is a list of dictionary of name fields, optional. :param descriptions: is a list of dictionary of name fields, optional. :param extras: is a list of dictionary of name fields, optional. :returns: POST result from requests package. """ data = {} data.update(base_data) data['names'] = self.extract_names(names) data['descriptions'] = self.extract_descriptions(descriptions) list_data = [] for extra in extras: list_data.append(extra) if len(list_data) > 0: data['extras'] = list_data result = self.put( source_owner_type, source_owner_id, 'sources', source_id, 'concepts', concept_id, **data) return result def create_org(self, base_data, extras=[]): """ Create organization :param base_data: is a dictionary of fields. :returns: response object. """ data = {} data.update(base_data) result = self.post('orgs', **data) return result def update_org(self, org_id, base_data, extras=[]): """ Update organization :param org_id: is the ID for the organization being updated. :param base_data: is a dictionary of fields. :returns: response object. """ data = {} data.update(base_data) result = self.post('orgs', org_id, **data) return result def create_source(self, owner_type, owner_id, base_data, extras=[]): """ Create source. :param owner_type: 'orgs' or 'users' :param owner_id: ID of the org/user/ owner :param base_data: Dictionary of fields for the new source version :param extras: Extras to save to the resource :returns: response object TODO(paynejd): create_sources extras not implemented """ data = {} data.update(base_data) result = self.post(owner_type, owner_id, 'sources', **data) return result def update_source(self, owner_type, owner_id, source_id, base_data, extras=[]): """ Update source owned by org. :param owner_type: 'orgs' or 'users' :param owner_id: ID of the org/user/ owner :param base_data: is a dictionary of fields. :param extras: Extras to save to the resource :returns: response object. """ data = {} data.update(base_data) result = self.put(owner_type, owner_id, 'sources', source_id, **data) return result def create_source_version(self, owner_type, org_id, source_id, base_data): """ Create a new source version. :param owner_type: 'orgs' or 'users' :param owner_id: ID of the org/user/ owner :param source_id: ID of the source :param base_data: Dictionary of fields for the new source version :returns: response object """ data = {} data.update(base_data) result = self.post(owner_type, org_id, 'sources', source_id, 'versions', **data) return result def update_resource_version(self, owner_type, owner_id, resource_id, version_id, resource_type, base_data): """ Update source version. Limits update to only the description and released fields for now. :param owner_type: 'orgs' or 'users' :param owner_id: ID of the org/user owner :param resource_id: ID of the source/collection :param version_id: ID of the source/collection_version :param resource_type: 'source' or 'collection' :param base_data: Dictionary of fields to update :returns: response object """ data = {} if 'description' in base_data: data['description'] = base_data['description'] if 'released' in base_data: data['released'] = base_data['released'] if 'retired' in base_data: data['retired'] = base_data['retired'] if 'version_external_id' in base_data: data['version_external_id'] = base_data['version_external_id'] result = self.put(owner_type, owner_id, resource_type, resource_id, version_id, **data) return result def update_collection(self, owner_type, owner_id, collection_id, base_data, extras=[]): """ Update collection. :param owner_type: 'orgs' or 'users' :param owner_id: ID of the org/user/ owner :param base_data: is a dictionary of fields. :param extras: Extras to save to the resource :returns: response object. """ data = {} data.update(base_data) result = self.put(owner_type, owner_id, 'collections', collection_id, **data) return result def create_mapping_from_concept(self, source_owner_type, source_owner_id, source_id, from_concept_id, data): """ Create a concept mapping from the specified concept The 'from_concept_url' is automatically set using the provided source_owner_type, 'source_owner_id', 'source_id', and 'from_concept_id'. If the from_concept is not stored in the provided source, use create_mapping(). :param source_owner_type: Either 'orgs' or 'users' :param source_owner_id: ID of the owner org/user :param source_id: ID of the source that will own the new mapping :param from_concept_id: ID of the from-concept :param data: A dictionary of all the data fields to POST :returns: POST result from requests package. """ data['from_concept_url'] = ('/' + source_owner_type + '/' + source_owner_id + '/sources/' + source_id + '/concepts/' + from_concept_id + '/') return self.create_mapping(source_owner_type, source_owner_id, source_id, data) def create_mapping(self, source_owner_type, source_owner_id, source_id, data): """ Create a mapping 'from_concept_url' and 'map-type' are required fields in the data dictionary. If internal mapping, must include 'to_concept_url'. If external mapping, must include 'to_source_url' and 'to_concept_code'. Refer to API documentation for details and other optional fields. :param source_owner_type: Either 'orgs' or 'users' :param source_owner_id: ID of the owner org/user (e.g. "WHO") :param source_id: ID of the source that will own the new mapping (e.g. "ICD-10") :param data: A dictionary of all the data fields to POST :returns: POST result from requests package. """ result = self.post(source_owner_type, source_owner_id, 'sources', source_id, 'mappings', **data) return result def update_mapping(self, source_owner_type, source_owner_id, source_id, mapping_id, data): """ Update a mapping TODO: Unclear what happens if changing between internal/external -- consider only allowing updates to external_id, map_type, to_concept_name, and extras. :param source_owner_type: Either 'orgs' or 'users' :param source_owner_id: ID of the owner org/user (e.g. "WHO") :param source_id: ID of the source that will own the new mapping (e.g. "ICD-10") :param mapping_id: ID of the mapping to update :param data: A dictionary of all the data fields to POST :returns: POST result from requests package. """ result = self.put(source_owner_type, source_owner_id, 'sources', source_id, 'mappings', mapping_id, **data) return result def create_collection_version(self, owner_type, org_id, collection_id, base_data): data = {} data.update(base_data) result = self.post(owner_type, org_id, 'collections', collection_id, 'versions', **data) return result def get_all_collections_for_user(self, username): return self.get('collections', params={'user': username, 'limit': 0}).json()