from __future__ import absolute_import, division, print_function, unicode_literals

import hashlib
import json
import logging
import os
import time
import warnings
from copy import deepcopy

from requests.exceptions import HTTPError
from six import iteritems, itervalues
from six.moves import range
from functools import update_wrapper
from .api import _AuthApiClient, SessionApiClient
# TODO (williams): change to from .api import paths

from .api.paths import (
    ABOUT_PATH, ASSAY_DEFAULT_AGGREGATION_RULE_PATH, ASSAYS_PATH, ASYNC_DRIVER_PATH, ASYNC_PATH,
    ATTACHMENT_PATH, CACHE_PATH, CLEAR_TIMINGS_PATH, COLUMN_ALIAS_PATH, COLUMN_DESCRIPTORS_PATH,
    COLUMN_FOLDER_PATH, COMPOUNDS_LINK_PATH, COMPOUNDS_PATH, CONFIG_PATH, CURRENT_USER_PATH,
    DATABASES_PATH, DATABASE_COLUMNS_PATH, DATA_SOURCE_ASSAY_FOLDER_RULES_PATH,
    DELETE_OBSERVATIONS_PATH, EXPORT_TO_MAESTRO_PATH, FREEFORM_COLUMNS_PATH, GROUPS_PATH,
    GROUP_MEMBERSHIP_PATH, IMPORT_PATH, INVALIDATE_ROLE_CACHE_PATH, LIVE_REPORTS_METADATA_PATH,
    LIVE_REPORTS_PATH, MODELS_PATH, NIGHTLY_TASK, OBSERVATION_PATH, PERMISSIONS_PATH, POSE_PATH,
    PROJECTS_PATH, PROTOCOLS_PATH, QUICK_TEST_MODELS_PATH, REFRESH_DB_CONSTANTS_PATH, TAGS_PATH,
    TIMINGS_PATH, TOOLS_PATH, USER_PATH, USERS_PATH, XTALS_PATH, DELETE_RESULTS_PATH)
from .models import (Assay, AssayDefaultAggregationRule, AsyncServiceTaskFailedError, ChangedEntity,
                     ChangedLiveReport, ChangedObservation, Column, ColumnAlias, ColumnDescriptor,
                     ColumnFilter, CompoundLink, Database, DatabaseColumn,
                     DataSourceAssayFolderRules, Folder, FreeformColumn, LiveReport,
                     LiveReportMetadata, Model, ModelAsyncDriver, ModelEncoder, Observation,
                     Project, QuickTestModel, RemovedLiveReportRow, ensure_string_list)

FINISHED_STATUS_TYPES = ['finished', 'killed', 'failed', 'malformed_output']

logger = logging.getLogger(__name__)


def _convert_show_poses(function):
    """
    Decorator to ensure backwards compatability with prior API arguments.

    :type function: func
    """

    def convert_parameters(self, live_report_id, params=None, *args, **kwargs):
        if params and 'show_poses' not in params and 'report_level' in params:
            return function(self, live_report_id, params, *args, **kwargs)

        params = deepcopy(params) or {}
        report_level = 'POSE' if params.get('show_poses') else params.get('report_level')

        if not report_level:
            warnings.warn("A 'report_level' is required, fetching it from Live Report.",
                          FutureWarning)
            report_level = getattr(
                self.live_report_metadata(live_report_id), 'report_level', 'PARENT')

        if 'show_poses' in params:
            del params['show_poses']
            warnings.warn(
                "The 'show_poses' parameter is deprecated and will soon be removed from LD."
                "Please use { 'report_level': 'POSE' } instead of { 'show_poses': True }",
                FutureWarning)

        if report_level != params.get('report_level'):
            params['report_level'] = report_level

        return function(self, live_report_id, params, *args, **kwargs)

    return update_wrapper(convert_parameters, function)


class ServerProcessingError(Exception):
    pass


class ApiClient(_AuthApiClient):
    """ Old client we want to move away from since it stores uname/pass """

    def __init__(self, *args, **kwargs):
        warnings.warn(
            'The `ApiClient` class was moved to `api.AuthApiClient`. This '
            'class is being deprecated in favor of `api.SessionApiClient`, or '
            '`LDClient.login` which returns an instance of `api.SessionApiClient`', FutureWarning)
        super(ApiClient, self).__init__(*args, **kwargs)


class LDClient(object):
    """
    Connection to LiveDesign server

    :type host: str
    :param host: The url to the LiveDesign server, e.g. 'http://localhost:9087/livedesign/api'

    :type username: str
    :param username: HTTP basic auth username for accessing the server

    :type password: str
    :param password: HTTP basic auth password

    :type session_id: str
    :param session_id: JSESSIONID from an existing session

    :type verify_ssl: str or bool or None
    :param verify_ssl: One of the following:
        - False (default): don't verify SSL certificates.
        - True: verify SSL certificates, using the white-list of certificates provided
                by Python's requests library. This will generally result in SSL
                failures if LD Client is subsequently invoked in environments with a
                custom SSL certificate setup.
        - None: verify SSL certificates, using a white-list of certificates provided
                the OS as curated by the LiveDesign team. This is known to fail on
                certain Windows setups.
        - A string: verify SSL certificates using a provided certificate bundle. The
                    string argument is an absolute path to the bundle.

    """

    def __init__(self,
                 host,
                 username=None,
                 password=None,
                 session_id=None,
                 token=None,
                 model_encoder=ModelEncoder,
                 verify_ssl=False):

        self.client = SessionApiClient(
            host,
            username=username,
            password=password,
            session_id=session_id,
            token=token,
            model_encoder=model_encoder,
            verify_ssl=verify_ssl)

    def get_privileges(self):
        """
        Retrieves user privileges

        :return: A dict with username, is_admin and can_build_protocols keys
        :rtype: dict

        """
        return self.client.get(service_path=CURRENT_USER_PATH, path='')

    def about(self):
        """
        Gets data from the about endpoint

        :return: A dict with information about LiveDesign server
        :rtype: dict

        """
        return self.client.get(service_path=ABOUT_PATH, path='')

    def assay_names(self, live_report_id):
        """
        Retrieves all assays in a Live Report.

        :param live_report_id: Live Report ID
        :type live_report_id: int

        :return: A list of assay names within the live report.
        :rtype: list of str
        """
        response = self.live_report_results_metadata(live_report_id)
        columns_details = response["columns"]
        assay_columns = {
            k: v for (k, v) in iteritems(columns_details) if v['column_type'] == "assay"
        }
        return [col['name'].split(' (')[0] for col in itervalues(assay_columns)]

    def get_folder_tree_data(self, project_id, folder_name):
        """
        Retrieves data tree under the specified folder name in a project.
        NOTE: This will only return assay columns from the tree.

        :param project_id: Project ID
        :type project_id: int

        :param folder_name: Name of the folder to retrieve data for.
        :type folder_name: str

        :return: List of data tree dictionaries that contain the given folder name.
        :rtype: List of dict
        """
        params = {"project_id": project_id}
        # Only search these three folders, "Project Favorites" are marked
        # from these folders.
        column_types = ["computed_property", "computational_model", "experimental_assay"]
        request = {
            "addable_column_type": ['assay', 'assay_do_not_aggregate'],
            # We only can import into assay columns
            "display_column_type": column_types,
            "value_type": [
                'attachment', 'boolean', 'date', 'datetime', 'float', 'image', 'molecule',
                'pdb_data', 'rationale', 'string', '3D', 'variable', 'integer'
            ],
            "full_text_search": folder_name,
            "exclude_add_all": True,
            "exclude_create": True,
            "exclude_clustering_models": True
        }
        response = self.client.post(
            service_path=COLUMN_FOLDER_PATH, path='/search', params=params, json_data=request)
        return response['results']

    def get_subfolders(self, folder_name, data):
        """
        Retrieves all the "children" and their data tree immediately under
        the given folder name.

        :param folder_name: Name of the folder to find subfolders for.
        :type folder_name: str

        :param data: json-formatted data received from LD server under the
                     given folder name.
        :type data: json

        :return: a dictionary of direct subfolder names and json data.
        :rtype: dict
        """
        if data['name'] != folder_name:
            logger.warn("Data name field does not match {}".format(folder_name))
        return {child['name']: child for child in data['children']}
        # cannot strip off the assay types here, otherwise assays
        # with multiple assay types will have only one child.

    def assays(self, database_name=None, project_ids=None, assay_name=None):
        """
        Retrieve all assays

        :type project_ids: List
        :param project_ids: Filters assays to those that belong to the listed projects

        :type assay_name: str
        :param assay_name: Name of the assay to retrieve

        :type database_name: str
        :param database_name: Filter down to assays in a single database (DEPRECATED)

        :return: List of :class:`models.Assay`
        """
        if not project_ids:
            project_ids = [project.id for project in self._projects_w_global()]
        params = {}
        if database_name:
            params['database_name'] = database_name

        params['project_ids'] = ','.join([str(pid) for pid in project_ids])
        if assay_name:
            params['assay_name'] = assay_name
        response = self.client.get(ASSAYS_PATH, '', params=params)
        return Assay.from_list(response)

    def assay(self, assay_name, database_name=None, project_ids=None):
        """
        Gets a single assay

        :type assay_name: str
        :param assay_name: Name of the assay to retrieve

        :type project_ids: List
        :param project_ids: Filters assays to those that belong to the listed projects

        :type database_name: str
        :param database_name: Filter down to assays in a single database (DEPRECATED)

        :return: A single :class:`models.Assay` object
        """
        assay_list = self.assays(
            database_name=database_name, project_ids=project_ids, assay_name=assay_name)
        if len(assay_list) == 0:
            raise RuntimeError("No assays found with name: {0}".format(assay_name))

        if len(assay_list) > 1:
            raise RuntimeError("Multiple assays found with name: {0}".format(assay_name))

        return assay_list[0]

    def get_or_create_assay(self, assay_name, assay_type_name, column_type, project_ids):
        """
        Get an assay by its assay_name, assay_type_name, and column_type or create a new assay column if it does
        not exist

        :param assay_name: name of the column

        :param assay_type_name: type of the assay. Must be 3D if column_type == 'THREE_D'

        :param column_type: type of the column. Must be a valid column type recognized by LiveDesign

        :param project_ids: ACLs on the column

        :return: the content of the persisted assay
        :rtype: dict
        """
        assay = {
            "assay_name": assay_name,
            "assay_type_name": assay_type_name,
            "column_type": column_type,
            "project_ids": project_ids
        }

        return self.client.post(ASSAYS_PATH, '/', json_data=[assay])[0]

    def data_source_assay_folder_rules_search(self, data_source_id):
        """
        Retrieve the DataSourceAssayFolderRules object containing the list of all Assay folder Rules for the specified
        data source id including the default catch-all rule which is the last rule in the list.

        Please check the docstring of :class:`models.AssayFolderRule` for reference for the definition of an Assay
        Folder rule and the default catch-all rule.

        :type data_source_id: string
        :param data_source_id: Data source id for which we want to retrieve the associated assay folder rules

        :return: :class:`models.DataSourceAssayFolderRules`
        """
        request = {
            "data_source_id_search_query": {
                "data_source_id": data_source_id
            },
            "search_type": "data_source_id_search"
        }
        response = self.client.post(
            service_path=DATA_SOURCE_ASSAY_FOLDER_RULES_PATH, path="/search", json_data=request)
        results = response.get('results', [])
        if not results:
            return DataSourceAssayFolderRules.from_dict({})
        return DataSourceAssayFolderRules.from_dict(results[0])

    def create_or_update_data_source_assay_folder_rules(self, data_source_assay_folder_rules):
        """
        Returns the newly created or updated DataSourceAssayFolderRules object. If the id of the passed in
        DataSourceAssayFolderRules object is null, this would try to create a new DataSourceAssayFolderRules object,
        otherwise it would try to update the DataSourceAssayFolderRules object associated with the data_source_id of the
        passed in DataSourceAssayFolderRules object. Note that the last rule in the list of rules in the passed in
        DataSourceAssayFolderRules object must be the default catch-all rule.

        Please check the docstring of :class:`models.AssayFolderRule` for reference for the definition of an Assay
        Folder rule and the default catch-all rule.

        :type data_source_assay_folder_rules: :class:`models.DataSourceAssayFolderRules`
        :param data_source_assay_folder_rules: A DataSourceAssayFolderRules folder rules object containing the list of
            individual rules which should be created(or updated if an assay folder rules object for the same data source
            exists)

        :return: :class:`models.DataSourceAssayFolderRules`
        """
        response = self.client.post(
            service_path=DATA_SOURCE_ASSAY_FOLDER_RULES_PATH,
            path="/",
            json_data=data_source_assay_folder_rules)
        return DataSourceAssayFolderRules.from_dict(response)

    def get_assay_default_aggregation_rules(self):
        """
        Retrieves the list of all Assay Default Aggregation Rules including the Server wide default rule which is the
        last rule in the list.

        Please check the docstring of :class:`models.AssayDefaultAggregationRule` for reference for the definition of an
        Assay Default Aggregation rule

        :return: List of :class:`models.AssayDefaultAggregationRule`
        """
        response = self.client.get(service_path=ASSAY_DEFAULT_AGGREGATION_RULE_PATH, path="")
        return AssayDefaultAggregationRule.from_list(response)

    def set_assay_default_aggregation_rules(self, assay_default_aggregation_rules):
        """
        Saves the list of Assay Default Aggregation Rules which are passed in as the parameters to the LiveDesign
        server. Note that these rules will override all pre-existing rules meaning all the older rules will be deleted.

        Please check the docstring of :class:`models.AssayDefaultAggregationRule` for reference for the definition of an
        Assay Default Aggregation rule

        :type assay_default_aggregation_rules: List of :class:`models.AssayDefaultAggregationRule`
        :param assay_default_aggregation_rules: The list of assay default aggregation rules to be saved. Note that the
            default server wide rule should be provided as the last rule with match_target, match_operator, match_operand
            having values as None

        :return: List of :class:`models.AssayDefaultAggregationRule`
        """
        response = self.client.post(
            service_path=ASSAY_DEFAULT_AGGREGATION_RULE_PATH,
            path="/batch",
            json_data=AssayDefaultAggregationRule.as_list(assay_default_aggregation_rules))
        return AssayDefaultAggregationRule.from_list(response)

    def databases(self):
        """
        Retrieve all databases

        :return: List of :class:`models.Database`
        """
        response = self.client.get(service_path=DATABASES_PATH, path="", params={})
        return Database.from_list(response)

    def database(self, database_name):
        """
        Retrieve a database by name

        :type database_name: str
        :param database_name: Name of the database to retrieve

        :return: A single :class:`models.Database` object
        """
        path = "/" + database_name
        response = self.client.get(service_path=DATABASES_PATH, path=path, params={})
        return Database.from_dict(response)

    def _get_compound_ids_from_response(self, response):
        """
        Get the list of compound ids from server response.

        :param response: response from server
        :type response: dict

        :return: List of the compound ids as strings
        :rtype: list of str
        """
        return [str(compound["id"]) for compound in response["results"]]

    def compound_search(self,
                        molecule,
                        search_type="EXACT",
                        max_results=20,
                        search_threshold=.7,
                        database_names=None,
                        project_id=0,
                        ignore_stereospecific=False):
        """
        Retrieves the corporate ids of the compounds that match the structure
        search

        :param molecule: SMARTS of the compounds to search
        :type molecule: str

        :param search_type: Type of search: EXACT, SIMILARITY, and SUBSTRUCTURE
        :type search_type: str

        :param max_results: Maximum number of results to return
        :type max_results: int

        :param search_threshold: Threshold for SIMILARITY searches
        :type search_threshold: double

        :param database_names: Databases to search compounds in
        :type database_names: List

        :param project_id: Project to perform search on in addition to the
        Global project
        :type project_id: int

        :param ignore_stereospecific: Determines whether or not ignore stereo
        information during search. This option is honored only for EXACT search
        type.
        :type ignore_stereospecific: bool

        :return: List of corporate ids
        :rtype: list of str
        """
        if not database_names:
            # search all databases
            database_names = [database.name for database in self.databases()]
        request = {
            "compound_search_type": "STRUCTURE_SEARCH",
            "project_id": project_id,
            "live_report_id": None,
            "structure_search_query": {
                "molecule": molecule,
                "search_type": search_type,
                "search_threshold": search_threshold,
                "max_results": max_results,
                "ignore_stereospecific": ignore_stereospecific
            },
            "database_search_query": {
                "databases": database_names
            }
        }
        response = self.client.post(service_path=COMPOUNDS_PATH, path='/search', json_data=request)
        return self._get_compound_ids_from_response(response)

    def compound_search_by_id(self, query, database_names=None, project_id=0):
        """
        Retrieves the corporate ids of the compounds that match the query

        :type query: str
        :param query: Corporate ID query string

        :type database_names: List
        :param database_names: Databases to search compounds in

        :type project_id: int
        :param project_id: Project to perform search on in addition to the Global project

        :return: List of corporate ids
        """
        if not database_names:
            database_names = [database.name for database in self.databases()]
        request = {
            "compound_search_type": "CORPORATE_ID_SEARCH",
            "project_id": project_id,
            "live_report_id": None,
            "corporate_id_search_query": {
                "query": query
            },
            "database_search_query": {
                "databases": database_names
            }
        }
        response = self.client.post(service_path=COMPOUNDS_PATH, path='/search', json_data=request)
        return self._get_compound_ids_from_response(response)

    def live_report_metadata(self, live_report_id):
        """
        Retrieve metadata for a single Live Report

        :type live_report_id: int

        :return: :class:`models.LiveReportMetadata`
        """
        response = self.client.get(
            service_path=LIVE_REPORTS_METADATA_PATH, path='/{0}'.format(live_report_id))

        return LiveReportMetadata.from_dict(response)

    def live_reports_metadata(self, project_ids=None):
        """
        Retrieve all Live Reports, LD 7.5 and above.

        :type project_ids: List of int
        :param project_ids: List of project ids to search

        :return: list of :class:`models.LiveReportMetadata`
        """
        if not project_ids:
            project_ids = [project.id for project in self._projects_w_global()]
        params = {'project_ids': ','.join([str(pid) for pid in project_ids])}
        request = {
            "search_type": "LIVE_REPORT_SEARCH",
            "live_report_search_query": {
                "project_ids": project_ids,
                "template": None,
                "alias": None
            }
        }
        response = self.client.post(
            service_path=LIVE_REPORTS_METADATA_PATH,
            path='/search',
            params=params,
            json_data=request)

        return LiveReportMetadata.from_list(response['results'])

    def live_reports(self, project_ids=None):
        """
        Retrieve all Live Reports

        :type project_ids: list[int] or None
        :param project_ids: List of project ids to search. If not specified, will search all accessible projects.

        :return: List of :class:`models.LiveReport`
        """
        if not project_ids:
            project_ids = [project.id for project in self._projects_w_global()]
        params = {'project_ids': ','.join([str(pid) for pid in project_ids])}
        request = {
            "search_type": "LIVE_REPORT_SEARCH",
            "live_report_search_query": {
                "project_ids": project_ids,
                "template": None,
                "alias": None
            }
        }
        response = self.client.post(
            service_path=LIVE_REPORTS_PATH, path='/search', params=params, json_data=request)

        return LiveReport.from_list(response['results'])

    def live_report(self, live_report_id):
        """
        Retrieves a single Live Report

        :param live_report_id: ID of the Live Report to retrieve
        :type live_report_id: int

        :return: :class:`models.LiveReport`
        """
        response = self.client.get(LIVE_REPORTS_PATH, '/{0}'.format(live_report_id))
        return LiveReport.from_dict(response)

    def live_report_rows(self, live_report_id):
        """
        Retrieves the rows from a single Live Report

        :param live_report_id: ID of the Live Report to retrieve rows from
        :type live_report_id: int

        :return: List of Rows
        """
        response = self.client.get(LIVE_REPORTS_PATH, '/{0}/additional_rows'.format(live_report_id))
        return response["additional_rows"]

    def list_users(self, include_permissions=True):
        """
        Retrieves the list of LD Users. Must be admin to use this method.

        :type include_permissions: bool
        :param include_permissions: controls if response contains the user permissions

        :return: List of users
        """
        return self.client.get(USERS_PATH, '', params={'include_permissions': include_permissions})

    def get_user(self, username):
        """
        Gets a user by username.

        Must be an admin to retrieve any user other than the current user.

        :type username: :class:`str`
        :param username: User's username

        :return: User dict
        """
        return self.client.get(USER_PATH, '/?username={}'.format(username))

    def invalidate_role_cache(self, user_names):
        """
        Invalidate the role cache for the given list of user names for LDAP mode

        Must be an admin in order to do this

        :type usernames: List of :class:`str`
        :param username: List of user_names whose role has changed
        """
        return self.client.post(INVALIDATE_ROLE_CACHE_PATH, '', json_data=user_names)

    def create_user(self,
                    username,
                    decimal_separator='.',
                    can_build_protocols=False,
                    is_admin=False):
        """
        Saves a new User to the LiveDesign server

        :type username: :class:`str`
        :param username: User's username

        :return: Integer (User ID)
        """
        data = {
            'username': username,
            'decimal_separator': decimal_separator,
            'can_build_protocols': can_build_protocols,
            'is_admin': is_admin
        }
        response = self.client.post(USERS_PATH, '', json_data=data)
        return response['id']

    def create_live_report(self, live_report):
        """
        Saves a new LiveReport to the LiveDesign server

        :type live_report: :class:`models.LiveReport`
        :param live_report: LiveReport to save

        :return: :class:`models.LiveReport`
        """
        data = live_report.as_dict(standard_required=True)
        response = self.client.post(LIVE_REPORTS_PATH, '', json_data=data)
        return LiveReport.from_dict(response)

    def copy_live_report(self, template_id, params):
        """
        Creates copy of an existing Live Report

        :type template_id: int
        :param template_id: ID of the Live Report to copy

        :type params: :class:`models.LiveReport`
        :param params: A :class:`models.LiveReport` object. This object
            will be used as a template for the copied LR,
            e.g. the name and description of the copied LR will come
            from this object instead of the source LR.

        :return: :class:`models.LiveReport`
        """
        response = self.client.post(
            service_path=LIVE_REPORTS_PATH, path='/{0}/copy'.format(template_id), json_data=params)
        return LiveReport.from_dict(response)

    def create_folder(self, folder_name, project_id="0"):
        """
        Creates a new folder

        :type folder_name: str
        :param folder_name: Name of the new folder

        :type project_id: str
        :param project_id: ID of the project for the folder

        :return: :class:`models.Folder`
        """
        folder = {"name": folder_name, "id": None, "project_id": project_id}
        response = self.client.post(service_path=TAGS_PATH, path='', json_data=folder)
        return Folder.from_dict(response)

    def update_live_report(self, live_report_id, live_report):
        """
        Updates a Live Report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to be updated

        :type live_report: :class:`models.LiveReport`
        :param live_report: updated Live Report object

        :return: :class:`models.LiveReport`
        """
        data = live_report.as_dict(standard_required=False)
        response = self.client.put(LIVE_REPORTS_PATH, '/{0}'.format(live_report_id), json_data=data)
        return LiveReport.from_dict(response)

    @_convert_show_poses
    def execute_live_report(self,
                            live_report_id,
                            params=None,
                            max_tries=30,
                            include_hidden_rows=None):
        """
        Executes a Live Report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to execute

        :type params: Dict
        :param params: Parameters for the Live Report execution

        :type max_tries: int
        :param max_tries: Maximum number of attempts to get a valid, non-zero version

        :type include_hidden_rows: bool
        :param include_hidden_rows: include rows which have been filtered or manually hidden (defaults to False)

        :return: JSON response from server
        """
        data = {}
        if include_hidden_rows is not None:
            if 'include_hidden_rows' in params:
                logger.warning(
                    'include_hidden_rows specified both in params and as an argument, ignoring the value specified in params'
                )
            if params is None:
                params = {}
            params['include_hidden_rows'] = include_hidden_rows
        for _ in range(max_tries):
            metadata = self.live_report_results_metadata(live_report_id, params)
            if metadata and metadata['version'] != 0:
                data.update(metadata)
                break
            time.sleep(1)
        else:
            raise ServerProcessingError('Unable to get a valid Live Report results within the'
                                        ' allowed number of tries.')

        response = self.client.get(
            service_path=LIVE_REPORTS_PATH,
            path='/{0}/results'.format(live_report_id),
            params=params)

        # we maintain compatibility with 7.9.x's "rows" entry format:
        data[u'rows'] = {}
        for k in list(response):
            if k not in list(data[u'rows']):
                data[u'rows'][k] = {u'cells': {}}

            for cell in list(response[k]):
                data[u'rows'][k][u'cells'][cell] = {u'values': []}

                if u'values' in response[k][cell]:
                    for v in response[k][cell][u'values']:
                        data[u'rows'][k][u'cells'][cell][u'values'].append({u'value': v})

        # note that 'data' does not include the following fields that were previously returned
        # in 7.9.x and below:
        # - id
        # - saved_date
        # - execution_timers
        # - projection
        # - pagination
        # - owner
        # - report_level
        return data

    @_convert_show_poses
    def live_report_results_metadata(self, live_report_id, params=None):
        """
        Retrieves the Live Report results metadata.

        :param live_report_id: Live Report ID
        :type live_report_id: int

        :return: JSON response from the server
        """
        response = self.client.get(
            service_path=LIVE_REPORTS_PATH,
            path="/{}/results/metadata".format(live_report_id),
            params=params)

        return response

    def export_live_report(self,
                           live_report_id,
                           export_type='sdf',
                           projection=None,
                           corporate_ids=None,
                           corporate_ids_list=None):
        """
        Exports a Live Report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to be exported

        :type export_type: str
        :param export_type: File type: sdf (default), csv, xls, pptx, pdf

        :type projection: list of int
        :param projection: A list of column IDs that should be exported. All columns are exported if empty or None

        :type corporate_ids: str
        :param corporate_ids: DEPRECATED: please use corporate_ids_list instead.
            A csv list of corporate_ids of compounds to be exported. e.g. "V123,V124,V125".
            All compounds are exported if empty or None

        :type corporate_ids_list: list
        :param corporate_ids_list: A list of corporate IDs to export. If provided,
            overrides the deprecated corporate_ids_field.

        :rtype: bytes
        """
        corporate_ids_to_send = None
        if corporate_ids is not None:
            warnings.warn(
                'In the LD Client function export_live_report, the corporate_ids '
                'parameter is deprecated and will soon be removed from LD. Use '
                'corporate_ids_list instead.', FutureWarning)
            if corporate_ids != "":
                corporate_ids_to_send = corporate_ids.split(",")
        else:
            corporate_ids_to_send = corporate_ids_list

        json_data = {
            'live_report_id': live_report_id,
            'type': export_type.upper(),
            'column_ids': list(projection) if projection else [],
            'entity_ids': corporate_ids_to_send if corporate_ids_to_send else []
        }
        return self.client.post(
            service_path=LIVE_REPORTS_PATH,
            path='/export',
            json_data=json_data,
            json_response=False,
            params={'type': None},
        )

    def delete_live_report(self, live_report_id):
        """
        Deletes the Live Report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to be deleted
        """
        self.client.delete(LIVE_REPORTS_PATH, '/{0}'.format(live_report_id))

    def column_descriptors(self, live_report_id, column_id=None):
        """
        Retrieves all column descriptors for a live report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to retrieve column descriptors for

        :type column_id: str
        :param column_id: <DEPRECATED> optional name of the column to return
            (please avoid using this and filter results
            by addable_column_id instead

        :return: List of :class:`models.ColumnDescriptor`
        """
        if column_id:
            warnings.warn(
                'The column_id field is deprecated and will soon be removed from LD. '
                'Please avoid using column_id, instead filter results using the addable_column_id field.',
                FutureWarning)
        request = {
            "search_type": "LIVE_REPORT_ID_SEARCH",
            "column_descriptor_search_query": {
                "live_report_id": live_report_id
            }
        }
        response = self.client.post(
            service_path=COLUMN_DESCRIPTORS_PATH, path='/search', json_data=request)

        column_descriptors = ColumnDescriptor.from_list(response['results'])

        if column_id:
            column_descriptors = [cd for cd in column_descriptors if cd.column_id == column_id]

        return column_descriptors

    def column_descriptor(self, live_report_id, addable_column_id):
        """
        Retrieves a single column descriptor from a live report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to retrieve the column descriptor from

        :type addable_column_id: int
        :param addable_column_id: ID of the addable column to retrieve the descriptor for

        :return: :class:`models.ColumnDescriptor`
        """
        column_descriptors = self.column_descriptors(live_report_id)
        filtered_col_desc = [
            col_desc for col_desc in column_descriptors
            if col_desc.addable_column_id == str(addable_column_id)
        ]
        if len(filtered_col_desc) > 0:
            return filtered_col_desc[0]
        else:
            raise ValueError(
                'A column descriptor for the requested addable column id does not exist in the requested Live Report'
            )

    def add_column_descriptor(self, live_report_id, column_descriptor):
        """
        Adds column descriptors to a live report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to add column descriptor to

        :type :class:`models.ColumnDescriptor`
        :param column_descriptor: Column descriptor to add

        :return: :class:`models.ColumnDescriptor`
        """

        # Switch to update if the column descriptor already exists
        matching_column_descriptors = self.column_descriptors(live_report_id,
                                                              column_descriptor.column_id)

        if matching_column_descriptors:
            column_descriptor.id = matching_column_descriptors[0].id
            return self.update_column_descriptor(live_report_id, column_descriptor)

        column_descriptor.id = None
        column_descriptor.live_report_id = live_report_id
        self.client.post(
            service_path=COLUMN_DESCRIPTORS_PATH, path='/', json_data=column_descriptor)

        return self.column_descriptors(live_report_id, column_descriptor.column_id)[0]

    def update_column_descriptor(self, live_report_id, column_descriptor):
        """
        Updates column descriptors to a live report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to update column descriptor for

        :type :class:`models.ColumnDescriptor`
        :param column_descriptor: Column descriptor to update

        :return: :class:`models.ColumnDescriptor`
        """
        column_descriptor.live_report_id = live_report_id
        self.client.put(
            service_path=COLUMN_DESCRIPTORS_PATH,
            path='/{0}'.format(column_descriptor.id),
            json_data=column_descriptor)

        return self.column_descriptors(live_report_id, column_descriptor.column_id)[0]

    def column_filters(self, live_report_id):
        col_filters = []
        column_descriptors = self.column_descriptors(live_report_id)
        for col_desc in column_descriptors:
            for raw_filt_dict in col_desc.filters:
                col_filter = ColumnFilter.parse_from_dict(raw_filt_dict)
                col_filter.addable_column_id = col_desc.addable_column_id
                col_filters.append(col_filter)
        return col_filters

    def column_filter(self, live_report_id, addable_column_id):
        filtered_col_filter_list = [
            filt for filt in self.column_filters(live_report_id)
            if str(filt.addable_column_id) == str(addable_column_id)
        ]
        if len(filtered_col_filter_list) > 0:
            return filtered_col_filter_list[0]
        else:
            return None

    def set_column_filter(self, live_report_id, column_filter):
        active_col_desc = self.column_descriptor(live_report_id, column_filter.addable_column_id)
        filters_dict = {
            key: value
            for (key, value) in iteritems(column_filter.as_dict())
            if value is not None and key != 'addable_column_id'
        }
        active_col_desc.filters = [filters_dict]
        self.update_column_descriptor(live_report_id, active_col_desc)
        return self.column_filter(live_report_id, column_filter.addable_column_id)

    def remove_column_filter(self, live_report_id, addable_column_id):
        active_col_desc = self.column_descriptor(live_report_id, addable_column_id)
        active_col_desc.filters = []
        self.update_column_descriptor(live_report_id, active_col_desc)

    def remove_all_column_filters(self, live_report_id):
        column_descriptors = self.column_descriptors(live_report_id)
        for col_desc in column_descriptors:
            if len(col_desc.filters) > 0:
                col_desc.filters = []
                self.update_column_descriptor(live_report_id, col_desc)

    def add_columns(self, live_report_id, addable_columns):
        """
        Adds column(s) to the Live Report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to add the models to

        :type addable_columns: List
        :param addable_columns: List of addable column IDs

        :return: List of columns in the Live Report
        """
        data = [{'op': 'add', 'path': 'addable_columns', 'value': addable_columns}]
        response = self.client.patch(
            service_path=LIVE_REPORTS_PATH, path='/{0}'.format(live_report_id), json_data=data)
        return response['addable_columns']

    def remove_columns(self, live_report_id, addable_columns):
        """
        Removes columns from a Live Report

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to remove the models from

        :type addable_columns: List
        :param addable_columns: List of addable column IDs

        :return: List of columns in the Live Report
        """
        live_report = self.live_report(live_report_id=int(live_report_id))
        columns = live_report.addable_columns

        live_report.addable_columns = list(set(columns) - set(addable_columns))
        response = self.update_live_report(live_report_id, live_report)
        return response.addable_columns

    def add_rows(self, live_report_id, rows):
        """
        Adds compounds to a LiveReport

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to add the compounds to

        :type rows: List
        :param rows: Corporate IDs of compounds to be added to the Live Report

        :return: List of compounds (additional_rows) in the Live Report
        """
        data = [{'op': 'add', 'path': 'additional_rows', 'value': rows}]
        self.client.patch(
            service_path=LIVE_REPORTS_PATH, path='/{0}'.format(live_report_id), json_data=data)
        response = self.client.get(
            service_path=LIVE_REPORTS_PATH, path='/{0}/additional_rows'.format(live_report_id))
        return response['additional_rows']

    def remove_rows(self, live_report_id, rows):
        """
        Removes compounds from a LiveReport

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to remove the compounds from

        :type rows: List
        :param rows: Corporate IDs of compounds to be removed from the Live Report

        :return: List of compounds (additional_rows) in the Live Report
        """
        data = [{'op': 'remove', 'path': 'additional_rows', 'value': rows}]
        self.client.patch(
            service_path=LIVE_REPORTS_PATH, path='/{0}'.format(live_report_id), json_data=data)
        response = self.client.get(
            service_path=LIVE_REPORTS_PATH, path='/{0}/additional_rows'.format(live_report_id))
        return response['additional_rows']

    def list_folders(self, project_ids=None):
        """
        Retrieves all available folders (AKA project_tags).

        :type project_ids: List of project ids
        :param project_ids: Limits the folders to those in the specified projects

        :return: List of :class:`models.Folder`
        """
        response = self.client.get(service_path=TAGS_PATH, path="/")
        if project_ids is None:
            return Folder.from_list(response)
        project_ids = ensure_string_list(project_ids)
        return {f for f in Folder.from_list(response) if f.project_id in project_ids}

    def project_tags(self, project_ids=None):
        """
        DEPRECATED: use `list_folders` instead

        Retrieves all tags (A.K.A. folders for live reports)

        :type project_ids: List of project ids
        :param project_ids: Limits the tags to those in the specified projects

        :return: List of :class:`models.Folder`
        """
        warnings.warn(
            'The `project_tags` function is being deprecated in favor of `list_folders`. Please switch over '
            'to using `list_folders` which has identical functionality.')
        return self.list_folders(project_ids)

    def create_tag(self, project_id, tag_name):
        """
        DEPRECATED: use `create_folder` instead

        Creates a new folder

        :type tag_name: str
        :param tag_name: Name of the new tag

        :type project_id: str
        :param project_id: ID of the project for the tag

        :return: :class:`models.Folder`
        """
        warnings.warn(
            'The `create_tag` function is being deprecated in favor of `create_folder`. Please switch over '
            'to using `create_folder` which has identical functionality.')
        return self.create_folder(tag_name, project_id)

    def ping(self):
        """
        Attempts to connect to the LiveDesign server

        :return: True if the connection was successful
        """
        return bool(self.client.get(service_path=ABOUT_PATH, path=''))

    def protocols(self, project_ids=None):
        """
        DEPRECATED: Retrieves list of protocols by project ID

        :type project_ids: List of project ids
        :param project_ids: Limits the protocols to those in the specified projects

        :return: List of :class:`models.Model`
        """
        warnings.warn(
            'ldclient.protocols() has been deprecated! Please switch to '
            'using ldclient.get_protocols_by_project_id()', FutureWarning)
        return self.get_protocols_by_project_id(project_ids)

    def get_protocols_by_project_id(self, project_ids=None):
        """
        Retrieves list of protocols by project ID

        :type project_ids: List of project ids
        :param project_ids: Limits the protocols to those in the specified projects

        :return: List of :class:`models.Model`
        """
        if not project_ids:
            project_ids = [project.id for project in self._projects_w_global()]
        project_ids = ensure_string_list(project_ids)
        response = self.client.post(
            service_path=MODELS_PATH,
            path="/search",
            json_data={
                'search_type': 'project_search',
                'project_model_search_query': {
                    'project_ids': project_ids,
                    'model_types': ['protocol']
                }
            })
        return [Model.from_dict(protocol) for protocol in response['results']]

    def protocol(self, protocol_id):
        """
        DEPRECATED: Retrieves a single protocol by protocol ID

        :type protocol_id: int
        :param protocol_id: ID of the protocol to retrieve

        :return: :class:`models.Model`
        """
        warnings.warn(
            'ldclient.protocol() has been deprecated! Please switch to '
            'using ldclient.get_protocol_by_id()', FutureWarning)
        return self.get_protocol_by_id(protocol_id)

    def get_protocol_by_id(self, protocol_id):
        """
        Retrieves a single protocol by protocol ID

        :type protocol_id: int
        :param protocol_id: ID of the protocol to retrieve

        :return: :class:`models.Model`
        """
        response = self.client.get(service_path=PROTOCOLS_PATH, path='/{0}'.format(protocol_id))
        return Model.from_dict(response)

    def get_protocol_id_by_name(self, protocol_name):
        """
        Searches for a protocol ID using the name

        :type protocol_name: str
        :param protocol_name: Name of the protocol

        :return: Protocol ID
        """
        matching_protocols = [p for p in self.protocols() if p.name == protocol_name]
        return int(matching_protocols[0].id) if matching_protocols else matching_protocols

    def list_protocols_by_name(self, protocol_name):
        """
        Searches for a protocol using the name

        :type protocol_name: str
        :param protocol_name: Name of the protocol

        :return: list of Protocols
        """
        matching_protocols = [p for p in self.protocols() if p.name == protocol_name]
        return matching_protocols

    def create_or_update_protocol(self, protocol):
        """
        Creates or updates a protocol

        :type protocol: :class:`models.Protocol`
        :param protocol: Protocol object to save

        :return: :class:`models.Protocol`
        """
        existing_protocol_id = self.get_protocol_id_by_name(protocol.name)
        if existing_protocol_id:
            protocol.id = existing_protocol_id
            return self.update_protocol(protocol.id, protocol)
        else:
            return self.create_protocol(protocol)

    def create_protocol(self, protocol):
        """
        Creates a protocol

        :type protocol: :class:`models.Model`
        :param protocol: Protocol object to save

        :return: :class:`models.Model`
        """
        response = self.client.post(service_path=PROTOCOLS_PATH, path='', json_data=protocol)
        return Model.from_dict(response)

    def update_protocol(self, protocol_id, protocol):
        """
        Updates a protocol

        :type protocol: :class:`models.Model`
        :param protocol: Protocol object to update

        :return: :class:`models.Model`
        """
        response = self.client.put(
            service_path=PROTOCOLS_PATH, path='/{0}'.format(protocol_id), json_data=protocol)
        return Model.from_dict(response)

    def get_models_by_project_id(self, project_ids=None):
        """
        Retrieves list of models by project ID

        :type project_ids: List of project ids
        :param project_ids: Limits the models to those in the specified projects

        :return: List of :class:`models.Model`
        """
        if not project_ids:
            projects = self._projects_w_global()
            project_ids = [int(p.id) for p in projects]

        project_ids = ensure_string_list(project_ids)
        response = self.client.post(
            service_path=MODELS_PATH,
            path="/search",
            json_data={
                'search_type': 'project_search',
                'project_model_search_query': {
                    'project_ids': project_ids,
                    'model_types': ['model']
                }
            })
        return Model.from_list(response['results'])

    def get_models_by_column_id(self, column_ids):
        """
        Retrieves a list of models by column_id

        :type column_ids: List of column ids
        :param column_ids: Limits the addable columns to these ids

        :return: List of :class:`models.Model`
        """
        column_ids = ensure_string_list(column_ids)
        request_data = {
            'addable_column_search_query': {
                'query': column_ids
            },
            'search_type': 'addable_column_search'
        }
        response = self.client.post(
            service_path=MODELS_PATH, path="/search", json_data=request_data)
        return [Model.from_dict(model) for model in response['results']]

    def model(self, model_id):
        """
        Retrieves a single model

        :type model_id: int
        :param model_id: ID of the model to retrieve

        :return: :class:`models.Model`
        """
        # TODO(Osorio): Combine the model method with the get_model_id_by_name, and
        # get_model_by_name
        response = self.client.get(service_path=MODELS_PATH, path="/{0}".format(model_id))
        return Model.from_dict(response)

    def get_model_id_by_name(self, model_name):
        """
        Retrieves a single model

        :type model_name: str
        :param model_name: Name of the model to retrieve

        :return: Model ID
        """
        return int(self.get_model_by_name(model_name).id)

    def get_model_by_name(self, model_name):
        """
        Retrieves a single model

        :type model_name: str
        :param model_name: Name of the model to retrieve

        :return: :class:`models.Model`
        """
        matching_models = [m for m in self.models() if m.name == model_name]
        if len(matching_models) == 0:
            raise RuntimeError("No models found with name: {0}".format(model_name))

        if len(matching_models) > 1:
            raise RuntimeError("Multiple models found with name: {0}".format(model_name))

        return matching_models[0]

    def create_or_update_model(self, model):
        """
        Creates or updates a model

        :type model: :class:`models.Model`
        :param model: Model object to save

        :return: :class:`models.Model`
        """
        try:
            existing_model = self.get_model_by_name(model.name)
        except RuntimeError:
            existing_model = None

        if existing_model:
            model.id = existing_model.id
            return self.update_model(model.id, model)
        else:
            return self.create_model(model)

    def create_model(self, model):
        """
        Creates a model

        :type model: :class:`models.Model`
        :param model: Model object to save

        :return: :class:`models.Model`
        """
        response = self.client.post(service_path=MODELS_PATH, path="", json_data=model)
        new_model = Model.from_dict(response)
        return new_model

    def update_model(self, model_id, model):
        """
        Updates an existing model

        :type model_id: int
        :param model_id: ID of the model to update

        :type model: :class:`models.Model`
        :param model: Model object to save

        :return: :class:`models.Model`
        """
        response = self.client.put(
            service_path=MODELS_PATH, path='/{0}'.format(model_id), json_data=model)
        return Model.from_dict(response)

    def rerun_models_async(self,
                           model_id=None,
                           column_id=None,
                           live_report_ids=None,
                           entity_ids=None,
                           failed_only=False):
        """
        Re-run all the models, optionally restricted to a given set of compounds and/or Live Reports.

        :param live_report_ids: live reports to be operated on, defaults to all Live Reports
        :type live_report_id: list of str

        :param entity_ids: compound ids to be operated on, defaults to all compounds
        :type entity_ids: list of str

        :param model_id: The model id to be operated on
        :type model_ids: list of str

        :param column_id: The column id to be operated on
        :type model_ids: list of str

        :param failed_only: Only re-run the models that have failed to produce results
        :type bool
        """
        return self.delete_model_results_async(
            model_id=model_id,
            column_id=column_id,
            live_report_ids=live_report_ids,
            entity_ids=entity_ids,
            failed_only=failed_only,
            rerun_models=True)

    def delete_model_results_async(self,
                                   model_id=None,
                                   column_id=None,
                                   live_report_ids=None,
                                   entity_ids=None,
                                   failed_only=False,
                                   rerun_models=False):
        """
        Delete model results

        :param live_report_ids: live reports to be operated on, defaults to all Live Reports
        :type live_report_id: list of str

        :param entity_ids: compound ids to be operated on, defaults to all compounds
        :type entity_ids: list of str

        :param model_id: The model id to be operated on, defaults to all models
        :type model_ids: str

        :param column_id: The column id to be operated on, defaults to all columns
        :type model_ids: str

        :param failed_only: Only re-run the models that have failed to produce results
        :type bool

        :param rerun_models: Rerun models immediately after result deletion
        :type bool
        """
        if [column_id, model_id].count(None) is not 1:
            raise RuntimeError('Please provide either a model_id or a column_id')

        # May both be omitted, but are mutually exclusive if one is provided
        if [live_report_ids, entity_ids].count(None) == 0:
            raise RuntimeError('Please provide an array of either live_report_ids or entity_ids')

        data = {
            'model_target': {
                'model_id': model_id,
                'column_id': column_id
            },
            'entity_target': {
                'live_report_ids': live_report_ids,
                'entity_ids': entity_ids,
                'failed_only': failed_only,
            },
            'rerun_model_immediately': rerun_models
        }

        return self.client.post(service_path=DELETE_RESULTS_PATH, path=ASYNC_PATH, json_data=data)

    def delete_model_observations_async(self,
                                        model_id,
                                        should_run=False,
                                        failed_only=False,
                                        include_derived=False):
        """
        Deletes an existing model's results

        :type model_id: int
        :param model_id: ID of the model to update

        :type should_run: bool
        :param should_run: Whether to rerun the observations or not

        :type failed_only: bool
        :param failed_only: Whether to rerun only the failed observations or not

        :type include_derived: bool
        :param include_derived: Whether to include the model's children

        :return: the task_id of the import upload task
        """
        data = {
            'model_id': model_id,
            'should_run': should_run,
            'failed_only': failed_only,
            'include_derived': include_derived,
            # ID of the request, filled in by the BE
            'id': None
        }

        return self.client.post(
            service_path=DELETE_OBSERVATIONS_PATH, path='/async', json_data=data)

    def models(self, project_ids=None):
        """
        Retrieves list of models

        :type project_ids: List of project ids
        :param project_ids: Limits the models to those in the specified projects

        :return: List of :class:`models.Model`
        """
        if not project_ids:
            projects = self._projects_w_global()
            project_ids = [int(p.id) for p in projects]

        project_ids = ensure_string_list(project_ids)
        response = self.client.post(
            service_path=MODELS_PATH,
            path="/search",
            json_data={
                'search_type': 'project_search',
                'project_model_search_query': {
                    'project_ids': project_ids,
                    'model_types': ['model']
                }
            })
        return Model.from_list(response['results'])

    def async_drivers(self):
        """
        Retrieves list of async driver

        :return: List of :class:`models.ModelAsyncDriver`
        """
        response = self.client.get(service_path=ASYNC_DRIVER_PATH, path="")
        return [ModelAsyncDriver.from_dict(driver) for driver in response]

    def async_driver(self, async_driver_id):
        """
        Retrieves a single async_driver

        :type async_driver_id: int
        :param async_driver_id: ID of the async_driver to retrieve

        :return: :class:`models.ModelAsyncDriver`
        """
        # TODO(Osorio): Combine the async_driver method with the get_async_driver_id_by_name, and
        # get_async_driver_by_name
        response = self.client.get(
            service_path=ASYNC_DRIVER_PATH, path="/{0}".format(async_driver_id))
        return ModelAsyncDriver.from_dict(response)

    def get_async_driver_id_by_name(self, async_driver_name):
        """
        Retrieves a single async_driver

        :type async_driver_name: str
        :param async_driver_name: Name of the async_driver to retrieve

        :return: ModelAsyncDriver ID
        """
        return int(self.get_async_driver_by_name(async_driver_name).id)

    def get_async_driver_by_name(self, async_driver_name):
        """
        Retrieves a single async_driver

        :type async_driver_name: str
        :param async_driver_name: Name of the async_driver to retrieve

        :return: :class:`models.ModelAsyncDriver`
        """
        matching_async_drivers = [m for m in self.async_drivers() if m.name == async_driver_name]
        if len(matching_async_drivers) == 0:
            raise RuntimeError("No async_drivers found with name: {0}".format(async_driver_name))

        if len(matching_async_drivers) > 1:
            raise RuntimeError(
                "Multiple async_drivers found with name: {0}".format(async_driver_name))

        return matching_async_drivers[0]

    def create_or_update_async_driver(self, async_driver):
        """
        Creates or updates a async_driver

        :type async_driver: :class:`models.ModelAsyncDriver`
        :param async_driver: ModelAsyncDriver object to save

        :return: :class:`models.ModelAsyncDriver`
        """
        existing_async_driver = self.get_async_driver_by_name(async_driver.name)
        if existing_async_driver:
            async_driver.id = existing_async_driver.id
            return self.update_async_driver(async_driver.id, async_driver)
        else:
            return self.create_async_driver(async_driver)

    def create_async_driver(self, async_driver):
        """
        Creates a async_driver

        :type async_driver: :class:`models.ModelAsyncDriver`
        :param async_driver: ModelAsyncDriver object to save

        :return: :class:`models.ModelAsyncDriverAsyncDriver`
        """
        response = self.client.post(service_path=ASYNC_DRIVER_PATH, path="", json_data=async_driver)
        new_driver = ModelAsyncDriver.from_dict(response)
        return new_driver

    def update_async_driver(self, async_driver_id, async_driver):
        """
        Updates an existing async_driver

        :type async_driver_id: int
        :param async_driver_id: ID of the async_driver to update

        :type async_driver: :class:`models.ModelAsyncDriver`
        :param async_driver: ModelAsyncDriver object to save

        :return: :class:`models.ModelAsyncDriver`
        """
        response = self.client.put(
            service_path=ASYNC_DRIVER_PATH,
            path='/{0}'.format(async_driver_id),
            json_data=async_driver)
        return ModelAsyncDriver.from_dict(response)

    def quicktest_model(self, model_spec):
        """
        Launches an asynchronous quicktest of a model

        :type async_driver_id: int
        :param async_driver_id: ID of the async_driver to update

        :type async_driver: :class:`models.ModelAsyncDriver`
        :param async_driver: ModelAsyncDriver object to save

        :return: :class:`models.ModelAsyncDriver`
        """
        d = model_spec.as_dict()
        files = d.pop('files')
        data = {'test_model_request': json.dumps(d)}
        response = self.client.post(
            service_path=QUICK_TEST_MODELS_PATH,
            path='/async_task',
            data=data,
            files=files,
            json_response=False)

        d = json.loads(response)
        d['files'] = {}
        return QuickTestModel.from_dict(d)

    @staticmethod
    def get_tsv_import_settings(database_name='pri'):
        """
        Return a mapping for storing just structures and the Corporate ID
        """
        params = {
            "writeBackDbName": database_name,
            "delimiter": "\t",
            "sourceLiveReportId": 0,
            "filename": None,
            "propNameToReflect": None,
            "corpIdGenPolicy": "AUTO_GENERATE",
            "loadIntoDatabase": True,
            "published": True,
            "overwrite": False,
            "useAutoIdentifiers": False,
            "assayPerRowFormat": True,
            "truncatePermitted": True,
            "loadStructuresOnly": False,
            "fileHasHeader": True,
            "assay_per_row_format": True,
            "overwrite_permitted": False,
            "use_auto_identifiers": False,
            "truncate_permitted": True,
            "load_structures_only": False,
            "identifier_column_name": "Corporate ID",
            "file_has_header": True,
            "file_header": [
                "Corporate ID", "Lot Number", "Assay Name", "Expt Result Value", "Expt Result Type",
                "Expt Result Units", "Expt Result Operator", "Assay Protocol",
                "Expt Result Std Dev", "Expt Result Desc", "Expt Date", "Expt Result Comment",
                "Expt Concentration", "Expt Conc Units", "Expt Nb Page", "Expt Notebook",
                "Expt Batch Number"
            ],
            "data_mapping_column_names": [
                "Import?", "Name", "Data Type", "Model Type", "Name in File"
            ],
            "data_mapping_rows": [
                [True, "Corporate ID", "String", "IDENTIFIER", "Corporate ID"],
                [True, "Lot Number", "Integer", "Lot Number", "Lot Number"],
                [True, "Assay Name", "String", "Assay Name", "Assay Name"],
                [True, "Expt Result Value", "Real", "Expt Result Value", "Expt Result Value"],
                [True, "Expt Result Type", "String", "Expt Result Type", "Expt Result Type"],
                [True, "Expt Result Units", "String", "Expt Result Units", "Expt Result Units"],
                [
                    True, "Expt Result Operator", "String", "Expt Result Operator",
                    "Expt Result Operator"
                ],
                [True, "Assay Protocol", "String", "Assay Protocol", "Assay Protocol"],
                [
                    True, "Expt Result Std Dev", "String", "Expt Result Std Dev",
                    "Expt Result Std Dev"
                ],
                [True, "Expt Result Desc", "String", "Expt Result Desc", "Expt Result Desc"],
                [True, "Expt Date", "String", "Expt Date", "Expt Date"],
                [
                    True, "Expt Result Comment", "String", "Expt Result Comment",
                    "Expt Result Comment"
                ],
                [True, "Expt Concentration", "String", "Expt Concentration", "Expt Concentration"],
                [True, "Expt Conc Units", "String", "Expt Conc Units", "Expt Conc Units"],
                [True, "Expt Nb Page", "String", "Expt Nb Page", "Expt Nb Page"],
                [True, "Expt Notebook", "String", "Notebook ID", "Expt Notebook"],
                [True, "Expt Batch Number", "String", "Expt Batch Number", "Expt Batch Number"],
            ]
        }

        return json.dumps(params)

    def load_assay_and_pose_data(self,
                                 sdf_file_name,
                                 sdf_file_sha1,
                                 mapping_file_name,
                                 mapping_file_sha1,
                                 three_d_file_name,
                                 three_d_file_sha1,
                                 project,
                                 corporate_id_column,
                                 live_report_name,
                                 published,
                                 properties,
                                 export_type='SDF',
                                 live_report_id=None,
                                 compound_source=None):
        """
        Start an import of assay and 3D pose data into LiveDesign.

        This function has its own :doc:`documentation page<./assay_and_pose_import>`.

        :type sdf_file_name: str
        :param sdf_file_name: Path to the SDF file containing the data to import.

        :type sdf_file_sha1: str
        :param sdf_file_sha1: Sha1 of the SDF file.
            If set to None, no file verification will take place.

        :type mapping_file_name: str
        :param mapping_file_name: Path to the mapping file for the upload.

        :type mapping_file_sha1: str
        :param mapping_file_sha1: Sha1 of the mapping file.
            If set to None, no file verification will take place.

        :type three_d_file_name: str
        :param three_d_file_name: Path to the 3d data zip archive containing
            3D data to import.

        :type three_d_file_sha1: str
        :param three_d_file_sha1: Sha1 of the 3d data zip archive.
            If set to None, no file verification will take place.

        :type project: :class:`models.Project` or str
        :param project: Reference to the project, either using
            the object or the project name, in which the new LiveReport
            will be created.
            Even if not creating a new LiveReport, a valid input
            must be provided.

        :type corporate_id_column: str
        :param corporate_id_column: Name of the field in the SDF file that
            contains the
            corporate id information.

        :type live_report_name: str
        :param live_report_name: Name of the LiveReport to be created.

        :type published: bool
        :param published: Whether to make the data available system wide (True)
            or just for that Live Report (False).

        :type properties: List
        :param properties: List of dicts containing information on the assay
            data to import. See the documentation page for a full description.

        :type export_type: str
        :param export_type: ``MAESTRO_SDF`` if including pose data and ``SDF``
            otherwise.

        :type live_report_id: int
        :param live_report_id: ID of the LiveReport to import the compounds into.
            If None, a new Live Report is created.

        :type compound_source: str
        :param compound_source: the source of the compound. A compound is a
            "virtual" if the source is "pri". Otherwise the compound is a
            "real". LD defaults this to "non_pri" when importing into a COMPOUND
            live report while on MATERIALS_SCIENCE mode or importing into a
            REACTANT live report. In all other scenarios, LD defaults this to
            "pri".

        :return: the task_id of the import upload task.
        """

        logger.info('Exporting as %s on project %s, SDF data %s, mapping data %s and 3d data %s',
                    export_type, project, sdf_file_name, mapping_file_name, three_d_file_name)

        import_settings = {
            'identifier': corporate_id_column,
            'type': export_type,
            'project_name': project,
            'live_report_name': live_report_name,
            'source_live_report_id': live_report_id,
            'published': published,
            'new_live_report': live_report_id is None,
            'add_data_to_live_report': True,
            'compounds_only': False,  # Always false (if true it only registers)
            'compound_source': compound_source,
            'assay_properties': [{
                'header_name': prop['name'],
                'assay_name': prop['model'],
                'assay_type': prop['endpoint'],
                'assay_units': prop['units']
            } for prop in properties]
        }

        data = {
            'import_settings': json.dumps(import_settings),
            'sdf_data_sha1': sdf_file_sha1,
            'mapping_data_sha1': mapping_file_sha1,
            'three_d_data_sha1': three_d_file_sha1
        }

        with open(sdf_file_name, 'rb') as sdf_file:
            sdf_data = sdf_file.read()
        sdf_data_full_path = os.path.basename(sdf_file_name)
        files = {'sdf_data': (sdf_data_full_path, sdf_data)}

        if mapping_file_name is not None:
            with open(mapping_file_name, 'rb') as mapping_file:
                mapping_data = mapping_file.read()
            mapping_data_full_path = os.path.basename(mapping_file_name)
            files['mapping_data'] = (mapping_data_full_path, mapping_data)

        if three_d_file_name is not None:
            with open(three_d_file_name, 'rb') as three_d_file:
                three_d_data = three_d_file.read()
            three_d_data_full_path = os.path.basename(three_d_file_name)
            files['three_d_data'] = (three_d_data_full_path, three_d_data)

        response = self.client.post(
            IMPORT_PATH,
            '/maestro_sdf',
            data=data,
            files=files,
            request_specific_headers={'Content-Type': 'multipart/form-data'})
        return response['task_id']

    def start_export_assay_and_pose_data(self,
                                         project,
                                         mapping_file_name,
                                         data_file_name,
                                         sha1,
                                         corporate_id_column,
                                         live_report_name,
                                         published,
                                         properties,
                                         export_type='SDF',
                                         live_report_id=None,
                                         compound_source=None):
        """
        Starts an upload of assay, pose and mapping data to LD.

        .. deprecated:: 8.7

            Please use the function :func:`load_assay_and_pose_data` instead.

        :type project: :class:`models.Project` or str
        :param project: Project object or project name to upload the data to

        :type mapping_file_name: str
        :param mapping_file_name: Name of the data map file

        :type data_file_name: str
        :param data_file_name: Name of the file to upload

        :type sha1: str
        :param sha1: Sha1 of the file to upload

        :type corporate_id_column: str
        :param corporate_id_column: Name of the column that contains the corporate id information

        :type live_report_name: str
        :param live_report_name: Name of the Live Report to be created

        :type published: bool
        :param published: Whether to make the data available system wide (True) or just for that
                Live Report (False).

        :type properties: List
        :param properties: List of dicts with the properties to be exported

        :type export_type: str
        :param export_type: "MAESTRO" for including pose data and "SDF" for no pose data

        :type live_report_id: int
        :param live_report_id: What Live Report to import the compounds to. If None, a new Live
                Report is created

        :type compound_source: str
        :param compound_source: the source of the compound. A compound is a "virtual" if the source is "pri". Otherwise
            the compound is a "real". LD defaults this to "non_pri" when importing into a COMPOUND live report while on
            MATERIALS_SCIENCE mode or importing into a REACTANT live report. In all other scenarios, LD defaults this to "pri"

        :return: the task_id of the import upload task
        """
        warnings.warn(
            "The 'start_export_poses_and_assay_data' function is deprecated and will "
            "be removed in a future version of LD Client. Please use the function "
            "'load_assay_and_pose_data' instead.", FutureWarning)

        logger.info('Exporting as %s on project %s, mapping %s and data %s', export_type, project,
                    mapping_file_name, data_file_name)

        import_settings = {
            'identifier': corporate_id_column,
            'type': export_type,
            'project_name': project,
            'live_report_name': live_report_name,
            'source_live_report_id': live_report_id,
            'published': published,
            'new_live_report': live_report_id is None,
            'add_data_to_live_report': True,
            'compounds_only': False,  # Always false (if true it only registers)
            'compound_source': compound_source,
            'assay_properties': [{
                'header_name': prop['name'],
                'assay_name': prop['model'],
                'assay_type': prop['endpoint'],
                'assay_units': prop['units']
            } for prop in properties]
        }

        data = {'import_settings': json.dumps(import_settings), 'sha1': sha1}

        with open(mapping_file_name, 'rb') as mapping_file:
            mapping_data = mapping_file.read()

        with open(data_file_name, 'rb') as import_file:
            import_data = import_file.read()

        files = {
            'mapping_data': (os.path.basename(mapping_file_name), mapping_data),
            'import_data': (os.path.basename(data_file_name), import_data)
        }

        response = self.client.post(
            IMPORT_PATH,
            '/async_task',
            data=data,
            files=files,
            request_specific_headers={'Content-Type': 'multipart/form-data'})
        return response['task_id']

    def wait_and_get_result_url(self, task_id, timeout=1200):
        """
        :param task_id: ID of the async task
        :param timeout: Wait for the elapsed timeout time, in seconds, before raising an exception. Default 1200 seconds
        :return:
        """
        response = self.client.get(ASYNC_PATH, '/{0}'.format(task_id))
        timeout = time.time() + timeout
        while response['status'] not in FINISHED_STATUS_TYPES:
            if time.time() > timeout:
                raise Exception("Timed out while waiting for export to finish")
            time.sleep(2)
            response = self.client.get(ASYNC_PATH, '/{0}'.format(task_id))

        if response['status'] != 'finished':
            raise AsyncServiceTaskFailedError('Async task {0}: Status = {1}'.format(
                task_id, response['status']))

        return response['result_url']

    def get_task_result(self, result_url):
        return self.client.get(result_url.replace("/api", "", 1), '')

    @staticmethod
    def get_sdf_import_settings(database_name='pri', generate_corp_ids=True):
        """
        Return a mapping for storing just structures and the Corporate ID
        """
        params = {
            "writeBackDbName": database_name,
            "delimiter": None,
            "sourceLiveReportId": 0,
            "filename": None,
            "propNameToReflect": None,
            "corpIdGenPolicy": "AUTO_GENERATE"
            if generate_corp_ids else "SPECIFIC_SDF_RECORD_PROPERTY",
            "loadIntoDatabase": True,
            "published": True,
            "overwrite": False,
            "useAutoIdentifiers": False,
            "assayPerRowFormat": False,
            "truncatePermitted": True,
            "loadStructuresOnly": True,
            "fileHasHeader": False,
            "assay_per_row_format": False,
            "overwrite_permitted": False,
            "use_auto_identifiers": False,
            "truncate_permitted": True,
            "load_structures_only": True,
            "identifier_column_name": "Corporate ID",
            "file_has_header": False,
            "file_header": None,
            "data_mapping_column_names": [
                "Import?", "Name", "Data Type", "Model Type", "Name in File", "Assay Name in DB",
                "Assay Type", "Assay Units", "Assay Conc", "Conc Units"
            ],
            "data_mapping_rows": [[
                True, "Corporate ID", "String", "Identifier", "Corporate ID", "Corporate ID", "",
                "", "", ""
            ]]
        }

        return json.dumps(params)

    def load_csv(self,
                 live_report_id,
                 filename,
                 project_name="Global",
                 published=False,
                 columns_only=False,
                 compounds_only=True,
                 identifier="SMILES"):
        """
        Load compounds into Live Design via csv

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to load the data to

        :type filename: str
        :param filename: Name of the csv file to load

        :type project_name: str
        :param project_name: Name of the project containing the LR

        :type published: bool
        :param published: Whether to add the data to the given LR only (default) or to make available
                        for other LiveReports.

        :type columns_only: bool
        :param columns_only: Whether to add the properties only (default) or to add compounds as well

        :type compounds_only: bool
        :param compounds_only: Register the compounds (default) or add the compounds and data.

        :type identifier: str
        :param identifier: SMILES / Corporate ID - Look up compounds by SMILES (default) or by
                        Corporate ID (useful when adding data values).

        :return: a list of dict objects - with each one representing a compound from the original
                        project table - containing data on that compound returned from the server.
        """

        import_settings = {
            "identifier": identifier,
            "type": "CSV_ROW_PER_COMPOUND",
            "project_name": project_name,
            "live_report_name": None,
            "source_live_report_id": live_report_id,
            "published": published,
            "new_live_report": False,
            "columns_only": columns_only,
            "compounds_only": compounds_only,
            "add_data_to_live_report": True
        }

        data = {'import_settings': json.dumps(import_settings)}
        files = {'import_data': (filename, open(filename, 'rb'), '')}
        logger.debug("Loading csv file %s into LiveDesign..." % filename)
        response = self.client.post(
            service_path=IMPORT_PATH,
            path='/web',
            data=data,
            files=files,
            request_specific_headers={'Content-Type': 'multipart/form-data'})
        return response['response']['import_responses']

    def register_compounds_sdf(self,
                               project_name,
                               file_contents,
                               file_name,
                               published=False,
                               import_assay_data=False,
                               use_corporate_id=False,
                               compound_source=None):
        """
        Register a set of compounds with the Seurat Server.

        :type project_name: str
        :param project_name: Name of the project to put the compounds in

        :type file_contents: bytes
        :param file_contents: the actual string file contents of the SD file to be exported to
                Seurat

        :type file_name: str
        :param file_name: Name of the file to be used as a reference for the import by the server

        :type import_assay_data: bool
        :param import_assay_data: Register the compounds (default) or add the compounds and data.

        :type published: bool
        :param published: Whether to add the data to the given LR only (default) or to make available
                for other LiveReports.

        :type use_corporate_id: bool
        :param use_corporate_id: If True, the 'Corporate ID' field in the sdf will be used for
                registration

        :type compound_source: str
        :param compound_source: the source of the compound. A compound is a "virtual" if the source is "pri". Otherwise
            the compound is a "real". LD defaults this to "non_pri" when importing into a COMPOUND live report while on
            MATERIALS_SCIENCE mode or importing into a REACTANT live report. In all other scenarios, LD defaults this to "pri"


        :return: a list of dict objects - with each one representing a compound from the original
                project table - containing data on that compound returned from the server.
        """
        # TODO(Osorio): Combine the registration calls into one, and pass the format and endpoint
        #   selection (/, /web, /maestro) as a parameter
        logger.info(
            'Making registration call on project %s, with file %s' % (project_name, file_name))

        export_type = 'SDF'

        import_settings = {
            'type': export_type,
            'project_name': project_name,
            'compounds_only': not import_assay_data,
            'published': published,
            "compound_source": compound_source,
        }
        if use_corporate_id:
            import_settings['identifier'] = 'Corporate ID'

        data = {
            'import_settings': json.dumps(import_settings),
            'sha1': hashlib.sha1(file_contents).hexdigest()
        }

        files = {'import_data': (file_name, file_contents)}

        response = self.client.post(
            IMPORT_PATH,
            '/',
            data=data,
            files=files,
            request_specific_headers={'Content-Type': 'multipart/form-data'})

        self._process_import_responses(response['import_responses'])
        return response['import_responses']

    @staticmethod
    def _process_import_responses(import_responses):
        error_messages = []
        general_messages = []
        failed = False

        for import_response in import_responses:
            if not import_response['success']:
                if import_response['messages']:
                    error_messages.append('\n'.join(import_response['messages']))
                failed = True
            else:
                if import_response['messages']:
                    general_messages.append('\n'.join(import_response['messages']))

        if general_messages:
            logger.info('Message from Server was: %s' % '\n'.join(general_messages))
        if failed:
            logger.error('Registration failed, responses: %s', import_responses)
            if error_messages:
                raise ServerProcessingError('\n'.join(error_messages))
            raise ServerProcessingError('An error occurred while registering compounds. '
                                        'Please contact your administrator.')

    def register_compounds_via_csv(self,
                                   project_name,
                                   file_contents,
                                   column_identifier,
                                   file_name,
                                   published=False,
                                   live_report_id=None,
                                   import_assay_data=False):
        """
        Register a set of compounds with the Seurat Server.

        :type project_name: str
        :param project_name: Name of the project to put the compounds in

        :type file_contents: file
        :param file_contents: The actual file contents of the SD file to be exported to Seurat.
                     Must be open(file,'rb')

        :type file_name: str
        :param file_name: Name of the file to be used as a reference for the import by the server

        :type published: bool
        :param published: Whether to add the data to the given LR only (default) or to make available
                        for other LiveReports.

        :type live_report_id: int
        :param live_report_id: ID of the Live Report to load the data to

        :type import_assay_data: bool
        :param import_assay_data: Register the compounds (default) or add the compounds and data.

        :type compound_source: str
        :param compound_source: the source of the compound. A compound is a "virtual" if the source is "pri". Otherwise
            the compound is a "real". LD defaults this to "non_pri" when importing into a COMPOUND live report while on
            MATERIALS_SCIENCE mode or importing into a REACTANT live report. In all other scenarios, LD defaults this to "pri"


        :return: List of corporate IDs
        """
        logger.info(
            'Making csv registration call on project %s, with file %s' % (project_name, file_name))

        import_settings = {
            'project_name': project_name,
            'identifier': column_identifier,
            'published': published,
            'type': 'CSV_ROW_PER_COMPOUND',
            'source_live_report_id': live_report_id,
            'live_report_name': None,
            'new_live_report': False,
            'compounds_only': not import_assay_data,
            'add_data_to_live_report': import_assay_data,
        }

        data = {'import_settings': json.dumps(import_settings)}

        files = {'import_data': (file_name, file_contents, 'application/vnd.ms-excel')}

        response = self.client.post(
            IMPORT_PATH,
            '/web',
            data=data,
            files=files,
            request_specific_headers={'Content-Type': 'multipart/form-data'})
        if not response['success']:
            logger.error('failed to load csv data')
            raise Exception('failed to load csv data')

        return [r['corporate_id'] for r in response['response']['import_responses']]

    def load_sdf(self,
                 live_report_id,
                 filename,
                 project_name="Global",
                 compounds_only=False,
                 compound_source=None,
                 published=False):
        """
        Load compounds into Live Design via sdf

        :type live_report_id: int
        :param live_report_id:

        :type filename: str
        :param filename: Name of the sdf file to load

        :type project_name: str
        :param project_name: Project to load the data to

        :type compounds_only: bool
        :param compounds_only: If True, the non-structure data of the SDF will be ignored


        :type compound_source: str
        :param compound_source: the source of the compound. A compound is a "virtual" if the source is "pri". Otherwise
            the compound is a "real". LD defaults this to "non_pri" when importing into a COMPOUND live report while on
            MATERIALS_SCIENCE mode or importing into a REACTANT live report. In all other scenarios, LD defaults this to "pri"

        :type published: bool
        :param published: Whether to add the data to the given LR only (default) or to make available
                        for other LiveReports.

        :return: List: import_responses
        """
        response = self.load_sdf_async(
            live_report_id=live_report_id,
            filename=filename,
            project_name=project_name,
            compounds_only=compounds_only,
            compound_source=compound_source,
            published=published)
        result_url = self.wait_and_get_result_url(response['task_id'])
        results = self.get_task_result(result_url)

        return results['import_responses']

    def load_sdf_async(self,
                       live_report_id,
                       filename,
                       published=False,
                       project_name="Global",
                       compounds_only=False,
                       file_content=None,
                       compound_source=None):
        """
        Load compounds into Live Design via sdf

        :type live_report_id: int
        :param live_report_id:

        :type filename: str
        :param filename: Name of the sdf file to load

        :type published: bool
        :param published: Whether to add the data to the given LR only (default) or to make available
                        for other LiveReports.

        :type project_name: str
        :param project_name: Project to load the data to

        :type compounds_only: bool
        :param compounds_only: If True, the non-structure data of the SDF will be ignored

        :type file_content: str
        :param file_content: the content of the SDF to load. If none is specified, attempt to get the file content
            using filename and the filesystem

        :type compound_source: str
        :param compound_source: the source of the compound. A compound is a "virtual" if the source is "pri". Otherwise
            the compound is a "real". LD defaults this to "non_pri" when importing into a COMPOUND live report while on
            MATERIALS_SCIENCE mode or importing into a REACTANT live report. In all other scenarios, LD defaults this to "pri"

        :return: the task_id of the import upload task
        """
        import_settings = {
            "identifier": None,
            "type": "SDF",
            "project_name": project_name,
            "live_report_name": None,
            "source_live_report_id": live_report_id,
            "published": published,
            "new_live_report": False,
            "compounds_only": compounds_only,
            "add_data_to_live_report": True,
            'compound_source': compound_source
        }

        data = {'import_settings': json.dumps(import_settings)}
        if file_content is None:
            with open(filename, 'rb') as input_file:
                file_content = input_file.read()

        files = {'import_data': file_content}
        logger.debug('loading sdf data %s into LiveDesign...' % filename)
        response = self.client.post(
            IMPORT_PATH,
            ASYNC_PATH,
            data=data,
            files=files,
            request_specific_headers={'Content-Type': 'multipart/form-data'})

        return response

    def get_project_id_by_name(self, project_name):
        """
        Retrieves a single project

        :type project_name: str
        :param project_name: Name of the project to retrieve

        :return: ID of the matching project
        """
        matching_projects = [p for p in self._projects_w_global() if p.name == project_name]
        return int(matching_projects[0].id) if matching_projects else matching_projects

    def list_permissions(self):
        """
        List all permissions. Must be admin to use this method.

        :return: list of all permissions dicts
        """
        return self.client.get(PERMISSIONS_PATH, '')

    def list_memberships(self):
        """
        List all memberships. Must be admin to use this method.

        :return: list of all memberships dicts
        """
        return self.client.get(GROUP_MEMBERSHIP_PATH, '')

    def create_group(self, name):
        """
        Saves a new Group to the LiveDesign server

        :type name: :class:`str`
        :param name: Group's name

        :return: Integer (Group ID)
        """
        data = {'name': name}
        response = self.client.post(GROUPS_PATH, '', json_data=data)
        return response['id']

    def raw_update_group(self, group_id, group):
        """
        Updates a group

        :type group_id: int
        :param group_id: ID of the group to retrieve
        :type group: dict
        :param group: Group object to update

        :return: JSON response from the server
        """
        response = self.client.put(
            service_path=GROUPS_PATH, path='/{0}'.format(group_id), json_data=group)
        return response

    def add_group_member(self, group_id, user_id):
        """
        Adds a given LD user to a group

        :type group_id: :class:`int`
        :param group_id: Group's ID
        :type user_id: :class:`int`
        :param user_id: User's ID

        :return: Response from LD
        """
        data = {'group_id': group_id, 'user_id': user_id}
        return self.client.post(GROUP_MEMBERSHIP_PATH, '', json_data=data)

    def delete_group_member(self, group_id, user_id):
        """
        Remove a given LD user from a group

        :type group_id: :class:`int`
        :param group_id: Group's ID
        :type user_id: :class:`int`
        :param user_id: User's ID

        :return: Response from LD
        """
        data = {'group_id': group_id, 'user_id': user_id}
        return self.client.delete(GROUP_MEMBERSHIP_PATH, '', json_data=data)

    def add_group_permission(self, group_id, project_id):
        """
        Gives a LD group access to a given project

        :type group_id: :class:`int`
        :param group_id: Group's ID
        :type project_id: :class:`int`
        :param project_id: Project's ID

        :return: Response from LD
        """
        data = {'group_id': group_id, 'project_id': project_id}
        return self.client.post(PERMISSIONS_PATH, '', json_data=data)

    def delete_group_permission(self, group_id, project_id):
        """
        Revokes a LD group's access to a given project

        :type group_id: :class:`int`
        :param group_id: Group's ID
        :type project_id: :class:`int`
        :param project_id: Project's ID

        :return: Response from LD
        """
        data = {'group_id': group_id, 'project_id': project_id}
        return self.client.delete(PERMISSIONS_PATH, '', json_data=data)

    def list_groups(self):
        """
        Get all groups

        @return: json
        """
        response = self.client.get(GROUPS_PATH, '')
        return response

    def raw_get_group(self, group_id):
        """
        Retrieves a single group by group ID

        :type group_id: int
        :param group_id: ID of the group to retrieve

        :return: JSON response from the server
        """
        response = self.client.get(service_path=GROUPS_PATH, path='/{0}'.format(group_id))
        return response

    def _projects_w_global(self):
        """
        Get all projects including the Global project

        @return: list of :class:`models.Project` objects
        """
        response = self.client.get(PROJECTS_PATH, '')

        return Project.from_list(response)

    def projects(self):
        """
        Get all projects excluding the Global project for non-admins

        @return: list of :class:`models.Project` objects
        """
        result = self._projects_w_global()

        # if the user is not an admin, we filter out the global project (id=0):
        privileges = self.client.get(CURRENT_USER_PATH, '')
        if not privileges[u'is_admin']:
            result = [p for p in result if p.id != u'0']

        return result

    def create_or_update_project(self, project):
        """
        Creates or updates a model

        :type model: :class:`models.Model`
        :param model: Model object to save

        :return: :class:`models.Model`
        """
        try:
            existing_project = None
            if project.id is not None:
                result = self.client.get(service_path=PROJECTS_PATH, path='/{0}'.format(project.id))
                existing_project = Project.from_dict(result)
        except RuntimeError:
            existing_project = None

        if existing_project:
            project.id = existing_project.id
            return self.update_project(project.id, project)
        else:
            return self.create_project(project)

    def create_project(self, project):
        """
        Creates a project

        :type project: :class:`models.Project`
        :param project: Project object to save

        :return: :class:`models.Project`
        """
        response = self.client.post(service_path=PROJECTS_PATH, path="", json_data=project)
        new_model = Project.from_dict(response)
        return new_model

    def update_project(self, project_id, project):
        """
        Updates an existing model

        :type model_id: int
        :param model_id: ID of the model to update

        :type model: :class:`models.Model`
        :param model: Model object to save

        :return: :class:`models.Model`
        """
        response = self.client.put(
            service_path=PROJECTS_PATH, path='/{0}'.format(project_id), json_data=project)
        return Project.from_dict(response)

    def export_xtals(self, sources, file_format='pse'):
        """
        Trigger the export of 3D model files (a.k.a. xtals)

        :type sources: List of dicts
        :param sources: Specifies the 3D models that should be exported.
                Each dict should have the following keys: model_name, corporate_id, pose_id, file_name, and structure_type.

                model_name: name of the 3D model column
                corporate_id: is the entity ID of the molecule on which the model was executed
                pose_id: is the pose ID visible in the LR result in the ID column when switching to pose mode
                file_name: is the attachment ID used to retrieve the original ligand and target files from /attachment
                structure_type: either 'LIGAND' or 'TARGET'

                NOTE: an export can be triggered with either pose_id OR file_name specified, they are NOT both mandatory

        :type file_format: str
        :param file_format: The format of the 3D model file to be exported; either 'pse' or 'mol2'

        :return: The ID of the requested export task
        :rtype: string
        """
        data = {
            'export_sources': sources or [],
            'format': file_format,
        }
        response = self.client.post(
            service_path=XTALS_PATH, path='/async_task', json_response=True, json_data=data)
        if 'task_id' not in response:
            raise RuntimeError("Unexpected response received from LiveDesign server: "
                               "{!r}".format(response))
        return response['task_id']

    def export_xtals_status(self, task_id):
        """
        Check the status of a given xtals export

        :type task_id: string
        :param task_id: The ID of an export task, as returned by export_xtals()

        :return: The status of the specified export task: a dict with "code" and "message" keys
        :rtype: dict
        """
        response = self.client.get(ASYNC_PATH, '/{0}'.format(task_id), json_response=True)
        if 'status' not in response:
            raise RuntimeError("Unexpected response received from LiveDesign server: "
                               "{!r}".format(response))
        return response['status']

    def export_xtals_result(self, result_url):
        """
        Get the content from an xtals export. This won't work unless export_xtals_status() returns
        a status code of "ready".

        :type result_url: string
        :param result_url: The result URL of a 3D export, as returned by the async task associated to the export

        :return: The content of the exported 3D model file
        :rtype: bytes
        """
        params = {'type': ''}  # to ensure that a JSON response isn't requested

        # If the exported files tend to be large, we may later choose to use the stream=True option
        # for the HTTP request so that we can return an iterater over the response content rather
        # than a string containing the whole file at once.
        try:
            response = self.client.get(
                result_url.replace("/api", "", 1), '', json_response=False, params=params)
        except HTTPError as err:
            if err.response is None:
                raise
            elif err.response.status_code == 404:
                raise RuntimeError("Export task result at {} is not finished yet "
                                   "or a server-side file was not found".format(result_url))
            elif err.response.status_code == 500:
                raise RuntimeError("Export task result at {} does not exist "
                                   "or a server-side I/O error occurred".format(result_url))
            else:
                raise
        return response

    def get_or_create_attachment(self,
                                 attachment_file_name,
                                 file_type,
                                 project_ids,
                                 file_obj=None,
                                 alternate_id=None,
                                 remote_file_name=None):
        """
        Get a attachment by its file data, file_name, file_type and ACLs if it exist, or upload an attachment and
        persist it in LiveDesign

        :param attachment_file_name: path to the file to be uploaded (not used
                                     when passing in file_obj)

        :param file_type: type of file. Either IMAGE, ATTACHMENT, or THREE_D

        :param project_ids: projects the file should have ACLs on

        :param alternate_id: ID for the file. MUST be unique in LiveDesign if not null. Otherwise generated on creation

        :param remote_file_name Slightly misleading.  This will be the 'original file name' attribute on the attachment after it gets uploaded

        :return: the content of the persisted attachment
        :rtype: dict
        """

        if remote_file_name is None:
            remote_file_name = attachment_file_name
        attachment_settings = {
            "id": alternate_id,
            "file_name": remote_file_name,
            "file_type": file_type,
            "project_id": project_ids
        }
        data = {"attachment_settings": json.dumps(attachment_settings)}

        # Allow users to pass in file-like objects. This ensures compatibility
        # with django forms
        attachment_data = file_obj
        if attachment_data is None:
            with open(attachment_file_name, 'rb') as attachment_file:
                attachment_data = attachment_file.read()

        files = {"attachment_data": attachment_data}

        return self.client.post(
            ATTACHMENT_PATH,
            '/',
            data=data,
            files=files,
            request_specific_headers={'Content-Type': 'multipart/form-data'})

    def get_attachment(self, alternate_id):
        """
        Retrieves the attachment given an alternate id

        :type alternate_id: string
        :param alternate_id: alternate ID of the attachment to retrieve

        :return: :class:`Bytes`
        """
        try:
            csrf_token = self.get_csrf_token()
        except HTTPError:
            csrf_token = 'unk'
            logger.warning('Failed to get CSRF token!')

        response = self.client.get(
            ATTACHMENT_PATH,
            '/{}'.format(alternate_id),
            params={'csrf_token': csrf_token},
            json_response=False)
        return response

    def get_attachment_metadata(self, alternate_ids):
        """
        Retrieves the attachment metadata given a collection of alternate ids

        :type alternate_ids: collection of strings
        :param alternate_ids: collection of alternate IDs of the attachments
            to retrieve

        :return: A list with one dictionary for each attachment. Each dictionary
            the alternate_id, file_name, file_type, project_id, updated_at,
            created_at and hash of the associated attachment
        """
        query = {
            'search_type': 'attachment_id',
            'attachment_id_query': {
                'attachment_ids': alternate_ids
            }
        }
        response = self.client.post(ATTACHMENT_PATH, '/search', json_data=query)
        return response['results']

    def get_or_create_pose(self,
                           column_id,
                           ligand_attachment_ids,
                           ligand_entity_id,
                           project_id,
                           published=False,
                           protein_attachment_ids=None,
                           pose_format=None):
        """
        Get a pose or create pose data on a column if it does not exist. New pose data must be created before hand
        via create_attachment

        :param column_id: column to put pose data on

        :param ligand_attachment_ids: ligand data on the pose (created via create_attachment)

        :param ligand_entity_id: entity id of the compound to put the pose data on

        :param protein_attachment_ids: protein data on the pose (created via create_attachment)

        :param published: whether or not the created pose should be published

        :param project_id: ACL on the pose

        :type pose_format: str
        :param pose_format: The file format of the 3D pose data; accepted values: 'pse', 'mae'

        :return: the content of the created pose
        :rtype: dict
        """
        ligand = {"structure_attachments": [], "entity_id": ligand_entity_id}

        for ligand_attachment_id in ligand_attachment_ids:
            structure_attachment = {
                "id": ligand_attachment_id,
                "structure_transformation": "FILE_UPLOAD"
            }
            ligand["structure_attachments"].append(structure_attachment)

        if protein_attachment_ids is None:
            protein = None
        else:
            protein = {"structure_attachments": []}
            for protein_attachment_id in protein_attachment_ids:
                structure_attachment = {
                    "id": protein_attachment_id,
                    "structure_transformation": "FILE_UPLOAD"
                }
                protein["structure_attachments"].append(structure_attachment)

        if pose_format is None:
            all_attachment_ids = set(ligand_attachment_ids)
            if protein_attachment_ids is not None:
                all_attachment_ids.update(protein_attachment_ids)
            pose_format = self._infer_shared_file_type_from_attachments(all_attachment_ids)

        if pose_format is None:
            pose_format = 'pse'

        pose = {
            "ligand": ligand,
            "protein": protein,
            "column_id": column_id,
            "published": published,
            "project_id": project_id,
            "pose_format": pose_format,
        }

        return self.client.post(POSE_PATH, '/', json_data=pose)

    def _infer_shared_file_type_from_attachments(self, attachment_ids):
        """
        Attempts to infer the single shared file type of a number of attachments
        by inspecting the extensions of their filenames. Attachments
        whose filename does not have an extension are ignored.

        :type alternate_ids: collection of strings
        :param alternate_id: collection of alternate IDs of the attachments
        to retrieve

        :return: the shared file extension, if it exists; otherwise None
        :rtype: string
        """
        shared_file_ext = None
        attachment_metadata = self.get_attachment_metadata(attachment_ids)
        for attachment in attachment_metadata:
            file_name = attachment['file_name']
            index = file_name.rfind('.')
            # If the file name has no extension, ignore
            if index == -1:
                continue
            ext = file_name[index + 1:]
            if ext == shared_file_ext:
                continue
            # If this file's extension conflicts with a previous one, fail
            if shared_file_ext is not None:
                return None
            shared_file_ext = ext
        return shared_file_ext

    def pose_search(self, live_report_id):
        """
        Returns all pose data contained in a LR.

        NOTE(francesc) if you want to be sure to have updated model results (e.g. model results were deleted and the LR
        was not re-executed since then) then you MUST call execute_live_report first!

        :return: all poses in a LR
        """
        request = {
            "pose_id_search_query": None,
            "pose_report_search_query": {
                "live_report_id": live_report_id
            }
        }

        response = self.client.post(service_path=POSE_PATH, path='/search', json_data=request)
        return response['results']

    def update_columns(self, columns):
        """
        Update configs for specific columns

        :param columns: list of columns objects to update
        :return: updated column list
        """

        return self.client.put(
            service_path=CONFIG_PATH, path='/columns/batch', json_data=list(columns))

    def update_properties(self, properties):
        """
        Update LiveDesign's config properties

        :param properties: a dictionary of key -> value
        :type properties: dict

        :return: updated properties
        """

        data = [{"key": key, "value": value} for key, value in iteritems(properties)]

        response = self.client.put(service_path=CONFIG_PATH, path='/batch', json_data=data)

        return {property['key']: property['value'] for property in response}

    def refresh_db_constants(self):
        """
        Refresh DbConstants

        :param update_pri: whether or not constants for pri should be updated
        :type update_pri: bool
        """
        data = {"update_pri": False}
        self.client.post(service_path=REFRESH_DB_CONSTANTS_PATH, path='/', json_data=data)

    def refresh_live_reports(self):
        """
        Force Livedesign to asyncronously calculate Livereports

        :return an async task
        """
        data = {"task_type": "refresh_live_report_cache"}

        return self.client.post(service_path=NIGHTLY_TASK, path='/', json_data=data)

    def invalidate_active_live_reports(self):
        """
        Invalidate all active live reports' cache

        :return an async task
        """
        data = {"task_type": "cache_invalidate"}
        return self.client.post(service_path=NIGHTLY_TASK, path='/', json_data=data)

    def invalidate_live_reports_by_live_report_ids(self, live_report_ids):
        """
        Invalidate the cache of all live reports with the given ids

        :param: live_report_ids: live report ids
        :type live_report_ids: collection

        :return: dict of response
        """
        changed_live_reports = [ChangedLiveReport(lr_id) for lr_id in live_report_ids]
        data = {
            "request_type": "live_report_change",
            "live_report_changes": changed_live_reports,
        }

        return self.client.post(service_path=CACHE_PATH, path='/invalidate', json_data=data)

    def invalidate_live_reports_by_entity_ids(self, entity_ids):
        """
        Invalidate the cache of all live reports with the given entity ids

        :param: entity_ids: entity_ids
        :type entity_ids: collection

        :return: dict of response
        """
        changed_entity = [ChangedEntity(ent_id) for ent_id in entity_ids]
        data = {
            "request_type": "entity_change",
            "entity_changes": changed_entity,
        }

        return self.client.post(service_path=CACHE_PATH, path='/invalidate', json_data=data)

    def invalidate_live_reports_by_observations(self, changed_observations):
        """
        Invalidate the cache of all live reports with the given observations

        :param changed_observations: collection of changed observations
        :type changed_observations: collection of ChangedObservation

        :return: dict of response
        """
        data = {
            "request_type": "observation_change",
            "observation_changes": list(changed_observations),
        }
        return self.client.post(service_path=CACHE_PATH, path='/invalidate', json_data=data)

    def invalidate_live_reports_by_removing_rows(self, removed_rows):
        """
        Invalidate the cache of given live reports with rows that are removed

        :param removed_rows: collection of live_report entity_id pairs removed
        :type removed_rows: collection of RemovedLiveReportRow

        :return: dict of response
        """
        data = {
            "request_type": "live_report_row_removal",
            "live_report_row_removals": list(removed_rows),
        }
        return self.client.post(service_path=CACHE_PATH, path='/invalidate', json_data=data)

    def get_csrf_token(self):
        """
        Get the csrf token from the environmentConfig.js response.

        """
        csrf_token = ''
        # To get the CSRF Token value from the backend, we currently pull
        # down & parse the 'environmentConfig.js' file. This is *very hacky*
        # but it's not available anywhere else at the moment.
        #
        # TODO: This should all be refactored out once we make this token
        # value directly available

        response = self.client.get(
            service_path=CONFIG_PATH,
            path='/environmentConfig.js',
            json_response=False,
            params={'type': ''})

        # If it looks like the file format has changed, we just give up...
        if not response.startswith(b'environmentConfig = ['):
            msg = 'Unrecognized response for environmentConfig.js'
            logger.error(msg)
            raise HTTPError(msg)

        # Loading the array only (see format in comment below)
        csrf_data = json.loads(response[len('environmentConfig = '):-1].decode('utf-8'))

        # Otherwise we expect it to follow the format
        # environmentConfig = [{"key": "key_name", "value": "xxx"},
        #   {"key2": "key2_name", "value": "yyy"}]
        # with one of the key/value pairs being u'CSRF_TOKEN' &
        # u'CSRF_TOKEN_VALUE'.
        for kv_pair in csrf_data:
            if kv_pair[u'key'] == u'CSRF_TOKEN':
                csrf_token = kv_pair[u'value']
                break
        else:
            msg = 'Could not find "CSRF_TOKEN" in environmentConfig.js:\n{0}'
            logger.error(msg)
            raise HTTPError(msg.format(csrf_data))

        return csrf_token

    def config(self):
        """
        Get the config properties

        :return: dict of LiveDesign properties
        """
        return self.client.get(service_path=CONFIG_PATH, path='')

    def timings(self):
        """
        Get the timings of annotated functions

        :return: dict of LiveDesign timings
        """
        return self.client.get(service_path=TIMINGS_PATH, path='')

    def clear_timings(self):
        """
        Clear the stored timings

        :return: nothing
        """
        self.client.get(service_path=CLEAR_TIMINGS_PATH, path='')

    def taskengine_about(self, path):
        """
        Get the about data from the taskengine server

        :param path: path to taskengine service
        :type path: str

        :return: dict of response
        """
        return self.client.get(service_path=path, path='')

    def replace_column(self, old_column_id, new_column_id, project_ids):
        """
        Replace a column with another column (in all LRs, plots, MPOs, Formulas, adv search queries, column descriptors,
        sorted columns, frozen columns, model dependent on columns, etc) in all projects specified

        :param old_column_id: the column ID to be replaced
        :type old_column_id: long

        :param new_column_id: the new column ID to be used
        :type new_column_id: long

        :param project_ids: list of project IDs where the replace should occur
        :type project_ids: collection

        :return: collection of LR IDs affected by the operation
        """
        data = {
            "old_column_id": old_column_id,
            "new_column_id": new_column_id,
            "project_ids": project_ids
        }

        return self.client.post(service_path=TOOLS_PATH, path='/replace_column', json_data=data)

    def database_columns(self):
        """
        Returns all database columns

        :return: List of :class:`models.DatabaseColumn`
        """
        response = self.client.get(service_path=DATABASE_COLUMNS_PATH, path='')

        return DatabaseColumn.from_list(response)

    def update_database_column(self, database_column):
        """
        Updates a database column. Currently, can only be used to update
        folder_path field. LD will ignore all other changes.

        :type: :class:`models.DatabaseColumn`
        :param: Database Column To update

        :return: :class:`models.DatabaseColumn`
        """
        response = self.client.put(
            service_path=DATABASE_COLUMNS_PATH,
            path='/{0}'.format(database_column.id),
            json_data=database_column)
        return DatabaseColumn.from_dict(response)

    def freeform_columns(self, project_id=None):
        """
        Retrieves all available freeform columns.

        :return: List of :class:`models.FreeformColumn`
        """

        if project_id is not None:
            logger.info('Attempted to use deprecated argument project_id')
        response = self.client.get(service_path=FREEFORM_COLUMNS_PATH, path='')
        return FreeformColumn.from_list(response)

    def get_freeform_column_by_id(self, freeform_column_id):
        """
        Retrieves freeform column with the specified id

        :type freeform_column_id: str
        :param freeform_column_id: Freeform column id of interest

        :return: :class:`models.FreeformColumn`
        """

        response = self.client.get(
            service_path=FREEFORM_COLUMNS_PATH, path='/{0}'.format(freeform_column_id))
        return FreeformColumn.from_dict(response)

    def create_freeform_column(self, freeform_column):
        """
        Creates a new freeform column

        :param freeform_column: freeform column model
        :type freeform_column_name: :class:`models.FreeformColumn`

        :return: :class:`models.FreeformColumn`
        """
        response = self.client.post(
            service_path=FREEFORM_COLUMNS_PATH, path='', json_data=freeform_column)

        freeform_column = FreeformColumn.from_dict(response)

        return freeform_column

    def get_observations_by_entity_ids_and_column_ids(self,
                                                      addable_column_ids,
                                                      entity_ids,
                                                      project_id=None,
                                                      live_report_id=None):
        """
        Retrieves all observations for a given set of criteria.

        NOTE that this only works for ASSAY, ASSAY_DO_NOT_AGGREGATE, and FREEFORM columns at the moment

        NOTE Must specify at least one param between project_id and live_report_id.

        :type addable_column_ids: list of str
        :param addable_column_ids: the column ids from which observations will be returned

        :param entity_ids: list of entity ids to query
        :type entity_ids: collection of str

        :type project_id: str
        :param project_id: Freeform columns for a specified project.
                           Note: This is a number encoded as a string, *not* the project name.

        :type live_report_id: str
        :param live_report_id: live report id

        :return: List of :class:`models.Observation`
        """
        payload = {
            'row_and_column_search_query': {
                'entity_ids': entity_ids,
                'project_id': project_id,
                'live_report_id': live_report_id,
                'addable_column_ids': addable_column_ids,
            }
        }

        response = self.client.post(
            service_path=OBSERVATION_PATH, path='/search', json_data=payload)

        return Observation.from_list(response.get('results', []))

    def add_freeform_column_values(self, observations):
        """
        Add values to a freeform column.

        :param observations: the observations to create
        :type observations: collection of :class:`models.Observation`

        :return: list of :class:`models.Observation`
        """

        response = self.client.post(
            service_path=OBSERVATION_PATH, path='/batch', json_data=list(observations))

        return Observation.from_list(response)

    def create_compound_link(self, real_entity_id, virtual_entity_id):
        """
        Link real and virtual compounds.

        :param real_entity_id: Real entity ID
        :type real_entity_id: str

        :param virtual_entity_id: Virtual entity ID
        :type virtual_entity_id: str

        :return: A single :class:`models.CompoundLink` object
        """
        payload = {
            'id': None,
            'real_entity_id': real_entity_id,
            'virtual_entity_id': virtual_entity_id,
        }
        response = self.client.post(service_path=COMPOUNDS_LINK_PATH, path='', json_data=payload)
        return CompoundLink.from_dict(response)

    def delete_compound_link(self, link_id):
        """
        Delete a compound link.

        :param link_id: Link id of the compound link.
        :type link_id: str
        """
        self.client.delete(service_path=COMPOUNDS_LINK_PATH, path='/{}'.format(link_id))

    def get_compound_links_by_real_entity_ids(self, real_entity_ids):
        """
        Find real compound links.

        :param real_entity_ids: a list of real compounds' entity IDs
        :type real_entity_ids: list

        :return: A List :class:`models.CompoundLink` object
        """
        payload = {
            'search_type': 'real_entity_id',
            'real_entity_id_query': {
                'entity_ids': real_entity_ids,
            }
        }
        response = self.client.post(
            service_path=COMPOUNDS_LINK_PATH, path='/search', json_data=payload)
        return CompoundLink.from_list(response.get('results', []))

    def create_export_to_maestro_task(self, live_report_id, addable_column_ids=[], entity_ids=[]):
        """
        Trigger generation of a maestro file from the given live report

        :param live_report_id: Link id of the compound link.
        :type live_report_id: str

        :param addable_column_ids: Addable column ids of columns to include in the output file
        :type addable_column_ids: list of str

        :param entity_ids: Entity ids to include in the output file
        :type entity_ids: list of str

        :return: The ID of the requested export task
        :rtype: string
        """
        data = {
            'live_report_id': live_report_id,
            'addable_column_ids': addable_column_ids,
            'entity_ids': entity_ids,
        }

        response = self.client.post(
            service_path=EXPORT_TO_MAESTRO_PATH, path='/async', json_response=True, json_data=data)
        if 'async_task_id' not in response:
            raise RuntimeError("Unexpected response received from LiveDesign server: "
                               "{!r}".format(response))
        return response['async_task_id']

    def wait_and_get_export_to_maestro_attachment_id(self, task_id):
        """
        Poll for the attachment ID of the maestro export

        :type task_id: string
        :param task_id: The ID of an export task, as returned by export_to_maestro()

        :return: The attachment id of the file, or None if it's not availabel yet
        :rtype: str
        """
        result_url = self.wait_and_get_result_url(task_id)
        result = self.get_task_result(result_url)

        attachment_id = result.get('attachment_alternate_id')
        if not attachment_id:
            raise RuntimeError("Unexpected response received from LiveDesign server: "
                               "{!r}".format(result))

        return attachment_id

    def export_to_maestro(self, live_report_id, addable_column_ids=(), entity_ids=()):
        """
        Trigger generation of a maestro file from the given live report

        :param live_report_id: id of the live report
        :type live_report_id: str

        :param addable_column_ids: Addable column ids of columns to include in the output file
        :type addable_column_ids: list of str

        :param entity_ids: Entity ids to include in the output file
        :type entity_ids: list of str

        :return: The contents of the maestro file
        :rtype: :class:`Bytes`
        """
        task_id = self.create_export_to_maestro_task(live_report_id, addable_column_ids, entity_ids)
        attachment_id = self.wait_and_get_export_to_maestro_attachment_id(task_id)
        return self.get_attachment(attachment_id)

    def column_aliases(self, project_id, live_report_id=None):
        """
        Retrieves all column aliases in the specified Project (required) & LiveReport (optional)

        :param project_id: Project ID of the project in which the column aliases are to be searched
        :type project_id: str

        :param live_report_id: LiveReport ID of the LR in which column aliases are to be searched
        :type live_report_id: str

        :return: list of :class:`models.ColumnAlias`
        """
        data = {}
        if live_report_id is None:
            data = {
                'search_type': 'project_search',
                'project_search_query': {
                    'project_id': project_id
                }
            }
        else:
            data = {
                'search_type': 'project_and_live_report_search',
                'project_and_live_report_search_query': {
                    'project_id': project_id,
                    'live_report_id': live_report_id
                }
            }
        response = self.client.post(service_path=COLUMN_ALIAS_PATH, path='/search', json_data=data)
        return ColumnAlias.from_list(response.get('results'))
