from __future__ import absolute_import, division, print_function, unicode_literals

import hashlib
import json
import logging
import os
import sys
import warnings

import time
from requests.exceptions import HTTPError
try:
    from six import iteritems, itervalues
    from six.moves import range
except ImportError:
    warnings.warn(
        "Six not found. Please install ldclient's dependencies by installing it as a package")

    def iteritems(d, **kw):
        return iter(d.items(**kw))

    def itervalues(d, **kw):
        return iter(d.values(**kw))


from .api import _AuthApiClient, SessionApiClient
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,
    DECIMAL_FORMATTING_RULE_PATH, DELETE_OBSERVATIONS_PATH, EXPORT_TO_MAESTRO_PATH,
    FORMULATIONS_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, COLUMN_GROUPS_PATH)
from .decorators import (_convert_show_poses, deprecated, _enforce_string_ids)
from .exceptions import AsyncServiceTaskFailedError, ServerProcessingError
from .models import (Assay, AssayDefaultAggregationRule, ColumnAlias, ColumnDescriptor,
                     ColumnFilter, ColumnGroup, CompoundLink, Database, DatabaseColumn,
                     DataSourceAssayFolderRules, DecimalFormattingRule, ensure_string_list, Folder,
                     FormulationData, FreeformColumn, ImportResponse, LiveReport,
                     LiveReportMetadata, Model, ModelAsyncDriver, ModelEncoder, Observation,
                     Project, UsernameToProjectIds, QuickTestModel)
# TODO (williams): change to from .api import paths
from .requests import ChangedLiveReport, ChangedEntity

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

logger = logging.getLogger(__name__)


class ApiClient(_AuthApiClient):
    """ Old client we want to move away from since it stores username/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: :class:`str`
    :param host: The url to the LiveDesign server, e.g. 'http://localhost:9087/livedesign/api'

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

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

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

    :type token: :class:`str`
    :param token: Authentication token

    :type model_encoder: :class:`models.ModelEncoder`
    :param model_encoder: Model Encoder

    :type verify_ssl: :class:`str` or :class:`bool` or :class:`NoneType`
    :param verify_ssl: One of the following:

        - False: 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 (default): If using http, pass verify=None into the requests library.
            If using https, verify SSL certificates, using a white-list of
            certificates provided the OS as curated by the LiveDesign team. There is
            also platform-specific code to attempt to handle the windows cert store and
            the MacOS keystore
        - 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=None):

        if sys.version_info < (3, 0, 0):
            # NOTE(badlato) Using FutureWarning here because DeprecationWarning is ignored by default in python2
            warnings.warn(
                "Python2 support is dropped after ldclient-8.10.", FutureWarning, stacklevel=2)

        self.client = SessionApiClient(
            host,
            username=username,
            password=password,
            session_id=session_id,
            token=token,
            model_encoder=model_encoder,
            verify_ssl=verify_ssl)
        self.ignore_deprecation_failure_for_version = None
        self.disable_string_identifier_conversion = False

    def _override_deprecation_failure(self, version):
        """
        Made available to temporarily disable deprecation failures for functions that are past their deprecation
        version. (Not recommended)

        :type version: :class:`tuple` of :class:`int`
        :param version: The version for which to disable teh deprecation failures

        :rtype: :class:`NoneType`
        :return: None
        """
        self.ignore_deprecation_failure_for_version = version

    def _disable_string_identifier_conversion(self, override):
        """
        By default, ldclient converts all identifiers to strings. To turn this off, you can call
        _disable_string_identifier_conversion(True).  Either way, warnings will be logged if a non-string identifier is passed as
        an argument.

        :type override: :class:`bool`
        :param override: True to turn off the conversion of identifiers, False to enable

        :rtype: :class:`NoneType`
        :return: None
        """
        self.disable_string_identifier_conversion = override

    def get_privileges(self):
        """
        Retrieves the current user's privileges

        :rtype: :class:`dict`
        :return: A :class:`models.User` object as a dictionary
        """
        return self.client.get(service_path=CURRENT_USER_PATH, path='')

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

        :rtype: :class:`dict`
        :return: An :class:`models.AboutInfo` object as a dict with information about LiveDesign server
        """
        return self.client.get(service_path=ABOUT_PATH, path='')

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

        :type live_report_id: :class:`str`
        :param live_report_id: Live Report ID

        :rtype: :class:`list` of :class:`str`
        :return: A list of assay names within the Live Report.
        """
        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)]

    @_enforce_string_ids('project_id')
    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.

        :type project_id: :class:`str`
        :param project_id: Project ID

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

        :rtype: :class:`list` of :class:`dict`
        :return: List of :class:`models.ColumnTreeNode` objects as dictionaries.
        """
        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.

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

        :type data: :class:`dict`
        :param data: json-formatted data received from LD server under the
                     given folder name. This dictionary should mirror the structure of :class:`models.ColumnFolderNode`.

        :rtype: :class:`dict`
        :return: a dictionary mapping direct subfolder names to a :class:`models.ColumnFolderNode` as a dictionary.
        """
        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.

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

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: Filters assays to those that belong to the listed projects

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

        :type database_name: :class:`str`
        :param database_name: (ignored)

        :rtype: :class:`list` of :class:`models.Assay`
        :return: List of Assays matching filter parameters
        """
        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)

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

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

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: Filters assays to those that belong to the listed projects

        :type database_name: :class:`str`
        :param database_name: (ignored)

        :rtype: :class:`models.Assay`
        :return: The Assay specified

        :raises: :class:`RuntimeError`
        """
        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]

    @_enforce_string_ids('project_ids')
    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.

        :type assay_name: :class:`str`
        :param assay_name: name of the column

        :type assay_type_name: :class:`str`
        :param assay_type_name: type of the assay. Must be '3D' if `column_type` is 'THREE_D'

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

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: ACLs on the column

        :rtype: :class:`dict`
        :return: the content of the :class:`models.PersistedAssay` as a 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]

    @_enforce_string_ids('data_source_id')
    def data_source_assay_folder_rules_search(self, data_source_id=None, data_source_name=None):
        """
        Retrieve the DataSourceAssayFolderRules object containing the list of all Assay Folder Rules
        for the specified data source, including the default catch-all rule which is the last rule
        in the list.

        The data source can be specified by ID or Name - if both are specified, the ID is
        ignored

        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: :class:`str`
        :param data_source_id: Data source id

        :type data_source_name: :class:`str`
        :param data_source_name: Data source name

        :rtype: :class:`models.DataSourceAssayFolderRules`
        :return: The Data Source Assay Folder Rules matching the search
        """
        if data_source_name is not None:
            if data_source_id is not None:
                warnings.warn(
                    "Both data source ID and name have been provided. The ID will be ignored.")
            database = self.database(data_source_name)
            data_source_id = database.id
        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):
        """
        If the id of the inputted :class:`models.DataSourceAssayFolderRules` object is null, this will create a new
        :class:`models.DataSourceAssayFolderRules` object. Otherwise, the existing object with the same ID will
        be updated.

        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.

        :rtype: :class:`models.DataSourceAssayFolderRules`
        :return: The newly created or updated DataSourceAssayFolderRules object
        """
        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

        :rtype: :class:`list` of :class:`models.AssayDefaultAggregationRule`
        :return: The list of Assay Default Aggregation Rules
        """
        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: :class:`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

        :rtype: :class:`list` of :class:`models.AssayDefaultAggregationRule`
        :return: The list of Assay Default Aggregation Rules set
        """
        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 get_decimal_formatting_rules(self):
        """
        Retrieves the list of all Decimal Formatting Rules

        Please check the docstring of :class:`models.DecimalFormattingRule` for reference for the definition of a
        Decimal Formatting rule

        :rtype: :class:`list` of :class:`models.DecimalFormattingRule`
        :return: The list of Decimal Formatting Rules
        """
        response = self.client.get(service_path=DECIMAL_FORMATTING_RULE_PATH, path="")
        return DecimalFormattingRule.from_list(response)

    def batch_create_or_update_decimal_formatting_rules(self, decimal_formatting_rules):
        """
        Saves the list of Decimal Formatting Rules which are passed in as the parameters to the LiveDesign
        server by creating/updating them.

        Please check the docstring of :class:`models.DecimalFormattingRule` for reference for the definition of a
        Decimal Formatting rule

        :type decimal_formatting_rules: :class:`list` of :class:`models.DecimalFormattingRule`
        :param decimal_formatting_rules: The list of decimal formatting rules to be saved.

        :rtype: :class:`list` of :class:`models.DecimalFormattingRule`
        :return: The list of persisted Decimal Formatting rules
        """
        response = self.client.post(
            service_path=DECIMAL_FORMATTING_RULE_PATH,
            path="/batch",
            json_data=DecimalFormattingRule.as_list(decimal_formatting_rules))
        return DecimalFormattingRule.from_list(response)

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

        :rtype: :class:`list` of :class:`models.Database`
        :return: The list of databases
        """
        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.

        Note that if the database doesn't exist an exception will be thrown.

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

        :rtype: :class:`models.Database`
        :return: The 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.

        :type response: :class:`dict`
        :param response: response from server

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

    @_enforce_string_ids('project_id')
    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

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

        :type search_type: :class:`str`
        :param search_type: Type of search: EXACT, SIMILARITY, or SUBSTRUCTURE

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

        :type search_threshold: :class:`float`
        :param search_threshold: Threshold for SIMILARITY searches. A number between 0 and 1.

        :type database_names: :class:`list` of :class:`str`
        :param database_names: (ignored)

        :type project_id: :class:`str`
        :param project_id: Project in which to perform search (in addition to the
            Global project)

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

        :rtype: :class:`list` of :class:`str`
        :return: List of corporate ids
        """
        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)

    @_enforce_string_ids('project_id')
    def compound_search_by_id(self, query, database_names=None, project_id=0):
        """
        Retrieves the corporate ids of the compounds that match the query.  Returns a maximum number of compounds
        according to the property MAX_ADVANCED_SEARCH_RETURN.

        :type query: :class:`str`
        :param query: Corporate ID query string, as would be entered through the GUI search by ID function
            e.g. "V*" to search for compounds with IDs that start with "V"

        :type database_names: :class:`list` of :class:`str`
        :param database_names: (ignored)

        :type project_id: :class:`str`
        :param project_id: Project in which to perform search (in addition to the Global project)

        :rtype: :class:`list` of :class:`str`
        :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)

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

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report

        :rtype: :class:`models.LiveReportMetadata`
        :return: Metadata of the specified Live Report
        """
        response = self.client.get(
            service_path=LIVE_REPORTS_METADATA_PATH, path='/{0}'.format(live_report_id))

        return LiveReportMetadata.from_dict(response)

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

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: List of project ids in which to search for Live Reports

        :rtype: :class:`list` of :class:`models.LiveReportMetadata`
        :return: Metadata for the Live Reports
        """
        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'])

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

        :type project_ids: :class:`list` of :class:`str` or :class:`NoneType`
        :param project_ids: List of project ids to search. If not specified, will search all accessible projects.

        :rtype: :class:`list` of :class:`models.LiveReport`
        :return: List of all Live Reports
        """
        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'])

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

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to retrieve

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

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

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to retrieve rows from

        :rtype: :class:`list` of :class:`str`
        :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: :class:`bool`
        :param include_permissions: controls if response contains the user permissions

        :rtype: :class:`list` of :class:`dict`
        :return: List of users (:class:`models.User` objects as dictionaries)
        """
        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

        :rtype: :class:`dict`
        :return: The user specified (a :class:`models.User` object as a dictionary)
        """
        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 user_names: :class:`list` of :class:`str`
        :param user_names: List of usernames whose role has changed

        :rtype: :class:`NoneType`
        :return: None
        """
        self.client.post(INVALIDATE_ROLE_CACHE_PATH, '', json_data=user_names)

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

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

        :type decimal_separator: :class:`str`
        :param decimal_separator: Decimal separator preference for the user

        :type can_build_protocols: :class:`bool`
        :param can_build_protocols: Whether or not the new user will be able to build protocols

        :type is_admin: :class:`bool`
        :param is_admin: Whether or not the new user will be an admin

        :type administered_project_ids: :class:`list` of :class:`str`
        :param administered_project_ids: List of numeric project ids for which the user can be Project admin

        :rtype: :class:`str`
        :return: ID of the user created
        """
        data = {
            'username': username,
            'decimal_separator': decimal_separator,
            'can_build_protocols': can_build_protocols,
            'is_admin': is_admin,
            'administered_project_ids': administered_project_ids
        }
        response = self.client.post(USERS_PATH, '', json_data=data)
        return response['id']

    def batch_create_or_update_users(self, users):
        """
        Saves a list of Users to the LiveDesign server

        :type users: :class:`list` of :class:`dict`
        :param users: A list of users that need to be created or updated

        :rtype: :class:`list` of :class:`dict`
        :return: The list of persisted users
        """
        response = self.client.post(USERS_PATH, '/batch', json_data=list(users))
        return response

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

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

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

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

        :type template_id: :class:`str`
        :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 Live Report,
            e.g. the name and description of the copied Live Report will come
            from this object instead of the source Live Report.

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

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

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

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

        :rtype: :class:`models.Folder`
        :return: The newly created 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)

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

        :type live_report_id: :class:`str`
        :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

        :rtype: :class:`models.LiveReport`
        :return: The updated Live Report
        """
        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
    @_enforce_string_ids('live_report_id')
    def execute_live_report(self,
                            live_report_id,
                            params=None,
                            max_tries=30,
                            include_hidden_rows=None):
        """
        Executes a Live Report.  If the Live Report exists in cache, it will be re-executed asynchronously.
        Otherwise, it will be re-executed synchronously.

        NOTE: the returned dict 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

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to execute

        :type params: :class:`dict`
        :param params: Parameters for the Live Report execution

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

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

        :rtype: :class:`dict`
        :return: A :class:`models.LiveReportResultsMetadata` object as a dict, but with an extra field "rows", which
            contains the contents of the cells in each row

        :raises: :class:`exceptions.ServerProcessingError`
        """
        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})
        return data

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

        :type live_report_id: :class:`str`
        :param live_report_id: Live Report ID

        :type params: :class:`dict`
        :param params: Parameters for the request

        :rtype: :class:`dict` or :class:`bytes`
        :return: A :class:`models.LiveReportResultsMetadata` object as a dict.  If the LiveReport does not exist in cache, returns an empty bytestring and asynchronously executes the LiveReport.
        """
        response = self.client.get(
            service_path=LIVE_REPORTS_PATH,
            path="/{}/results/metadata".format(live_report_id),
            params=params)
        return response

    @_enforce_string_ids('live_report_id', 'corporate_ids', 'corporate_ids_list')
    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: :class:`str`
        :param live_report_id: ID of the Live Report to be exported

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

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

        :type corporate_ids: :class:`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: :class:`list` of :class:`str`
        :param corporate_ids_list: A list of corporate IDs to export. If provided,
            overrides the deprecated corporate_ids_field.

        :rtype: :class:`bytes`
        :return: The exported Live Report
        """
        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},
        )

    @_enforce_string_ids('live_report_id')
    def delete_live_report(self, live_report_id):
        """
        Deletes a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to be deleted

        :rtype: :class:`NoneType`
        :return: None
        """
        self.client.delete(LIVE_REPORTS_PATH, '/{0}'.format(live_report_id))

    @_enforce_string_ids('live_report_id', 'column_id')
    def column_descriptors(self, live_report_id, column_id=None):
        """
        Retrieves all column descriptors for a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to retrieve column descriptors for

        :type column_id: :class:`str`
        :param column_id: **DEPRECATED**: optional name of the column to return
            (please avoid using this and filter results
            by addable column ID instead)

        :rtype: :class:`list` of :class:`models.ColumnDescriptor`
        :return: List of all column descriptors
        """
        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

    @_enforce_string_ids('live_report_id', 'addable_column_id')
    def column_descriptor(self, live_report_id, addable_column_id):
        """
        Retrieves a single column descriptor from a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to retrieve the column descriptor from

        :type addable_column_id: :class:`str`
        :param addable_column_id: ID of the addable column to retrieve the descriptor for

        :rtype: :class:`models.ColumnDescriptor`
        :return: The specified column descriptor

        :raises: :class:`ValueError`
        """
        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'
            )

    @_enforce_string_ids('live_report_id')
    def add_column_descriptor(self, live_report_id, column_descriptor):
        """
        Adds a column descriptor to a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to add the column descriptor to

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

        :rtype: :class:`models.ColumnDescriptor`
        :return: The newly added column descriptor
        """

        # 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]

    @_enforce_string_ids('live_report_id')
    def update_column_descriptor(self, live_report_id, column_descriptor):
        """
        Updates a column descriptor to a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to update the column descriptor for

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

        :rtype: :class:`models.ColumnDescriptor`
        :return: The updated column descriptor
        """
        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]

    @_enforce_string_ids('live_report_id')
    def column_filters(self, live_report_id):
        """
        Retrieves a list of column filters for a given Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report

        :rtype: :class:`list` of :class:`models.ColumnFilter` objects
        :return: A list of column filters in the Live Report
        """
        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

    @_enforce_string_ids('live_report_id', 'addable_column_id')
    def column_filter(self, live_report_id, addable_column_id):
        """
        Retrieves a single column filter

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report containing the column filter

        :type addable_column_id: :class:`str`
        :param addable_column_id: ID of the addable column

        :rtype: :class:`models.ColumnFilter`
        :return: The column filter matching the 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

    @_enforce_string_ids('live_report_id')
    def set_column_filter(self, live_report_id, column_filter):
        """
        Add a column filter to a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to add the column filter to

        :type column_filter: :class:`models.ColumnFilter`
        :param column_filter: The column filter to add

        :rtype: :class:`models.ColumnFilter`
        :return: The column filter created

        :raises: :class:`ValueError`
        """
        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)

    @_enforce_string_ids('live_report_id', 'addable_column_id')
    def remove_column_filter(self, live_report_id, addable_column_id):
        """
        Removes a column filter on a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to remove the filter from

        :type addable_column_id: :class:`str`
        :param addable_column_id: ID of the addable column that the column filter is on

        :rtype: :class:`NoneType`
        :return: None

        :raises: :class:`ValueError`
        """
        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)

    @_enforce_string_ids('live_report_id')
    def remove_all_column_filters(self, live_report_id):
        """
        Removes all column filters from a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to remove column filters from

        :rtype: :class:`NoneType`
        :return: None
        """
        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)

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

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to add the columns to

        :type addable_columns: :class:`list` of :class:`str`
        :param addable_columns: List of addable column IDs to add

        :rtype: :class:`list` of :class:`str`
        :return: List of IDs of the 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']

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

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to remove the columns from

        :type addable_columns: :class:`list` of :class:`int`
        :param addable_columns: List of addable column IDs to remove

        :rtype: :class:`list` of :class:`str`
        :return: List of IDs of the columns in the Live Report
        """
        live_report = self.live_report(live_report_id=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

    @_enforce_string_ids('live_report_id')
    def add_rows(self, live_report_id, rows):
        """
        Adds compounds to a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to add the compounds to

        :type rows: :class:`list` of :class:`str`
        :param rows: Corporate IDs of compounds to be added to the Live Report

        :rtype: :class:`list` of :class:`str`
        :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']

    @_enforce_string_ids('live_report_id')
    def remove_rows(self, live_report_id, rows):
        """
        Removes compounds from a Live Report

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to remove the compounds from

        :type rows: :class:`list` of :class:`str`
        :param rows: Corporate IDs of compounds to be removed from the Live Report

        :rtype: :class:`list` of :class:`str`
        :return: List of compound IDs (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']

    @_enforce_string_ids('project_ids')
    def list_folders(self, project_ids=None):
        """
        Retrieves all available folders (AKA project tags).

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: Limits the folders to those in the specified projects

        :rtype: :class:`list` of :class:`models.Folder`
        :return: List of folders
        """
        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}

    @deprecated(replacement="list_folders")
    @_enforce_string_ids('project_ids')
    def project_tags(self, project_ids=None):
        """
        Retrieves all tags (A.K.A. folders for live reports)

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: Limits the tags to those in the specified projects

        :rtype: :class:`list` of :class:`models.Folder`
        :return: List of tags
        """
        return self.list_folders(project_ids)

    @deprecated(replacement="create_folder")
    @_enforce_string_ids('project_id')
    def create_tag(self, project_id, tag_name):
        """
        Creates a new folder

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

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

        :rtype: :class:`models.Folder`
        :return: The newly created tag
        """
        return self.create_folder(tag_name, project_id)

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

        :rtype: :class:`bool`
        :return: True if the connection was successful

        :raises: :class:`RuntimeError`, :class:`HTTPError`
        """
        return bool(self.client.get(service_path=ABOUT_PATH, path=''))

    @deprecated(replacement="get_protocols_by_project_id")
    @_enforce_string_ids('project_ids')
    def protocols(self, project_ids=None):
        """
        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: Limits the protocols to those in the specified projects

        :rtype: :class:`list` of :class:`models.Model`
        :return: List of protocols in the specified projects
        """
        return self.get_protocols_by_project_id(project_ids)

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

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: Limits the protocols to those in the specified projects

        :rtype: :class:`list` of :class:`models.Model`
        :return: List of protocols in the specified projects
        """
        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']]

    @deprecated(replacement="get_protocol_by_id")
    @_enforce_string_ids('protocol_id')
    def protocol(self, protocol_id):
        """
        :type protocol_id: :class:`str`
        :param protocol_id: ID of the protocol to retrieve

        :rtype: :class:`models.Model`
        :return: The specified protocol
        """
        return self.get_protocol_by_id(protocol_id)

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

        :type protocol_id: :class:`str`
        :param protocol_id: ID of the protocol to retrieve

        :rtype: :class:`models.Model`
        :return: The specified protocol
        """
        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, include_archived=False):
        """
        Searches for a protocol ID using the name.

        **NOTE**: This function returns the protocol ID as an int. It is best practice to cast this ID to a string before
        passing it back to LiveDesign. In a future release of LDClient, this function will return the ID as a string.

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

        :type include_archived: :class:`bool`
        :param include_archived: Boolean to determine whether we include archived protocols in the result, defaults to False.

        :rtype: :class:`int` or :class:`list`
        :return: Protocol ID, or empty list if no protocol with input name exists
        """
        matching_protocols = self.list_protocols_by_name(protocol_name, include_archived=include_archived)

        if len(matching_protocols) > 1:
            warnings.warn(
                "get_protocol_id_by_name found multiple protocols for name %s. Only one will be returned at random. "
                "This function will error out on 9.0 when there are multiple matches. It is highly recommended that "
                "you use list_protocol_ids_by_name if the name is expected to match multiple protocols" % protocol_name,
                FutureWarning)
        return int(matching_protocols[0].id) if matching_protocols else matching_protocols

    def list_protocol_ids_by_name(self, protocol_name, include_archived=False):
        """
        Returns a list of IDs of any protocols with a matching name.

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

        :type include_archived: :class:`bool`
        :param include_archived: Boolean to determine whether we include archived protocols in the result, defaults to False.

        :rtype: :class:`list`
        :return: Protocol IDs as strings, or empty list if no protocol with input name exists
        """
        matching_protocols = self.list_protocols_by_name(protocol_name, include_archived=include_archived)
        matching_protocol_ids = [p.id for p in matching_protocols]
        return matching_protocol_ids

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

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

        :type include_archived: :class:`bool`
        :param include_archived: Boolean to determine whether we include archived protocols in the result, defaults to True.

        :rtype: :class:`list` of :class:`models.Model`
        :return: List of matching Protocols, including archived Protocols
        """
        matching_protocols = [p for p in self.protocols() if p.name == protocol_name]
        if not include_archived:
            matching_protocols = [p for p in matching_protocols if p.archived == False]
        return matching_protocols

    def create_or_update_protocol(self, protocol):
        """
        Creates or updates a protocol. If a protocol with the same name already exists, it will be updated. Otherwise, it will be created.

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

        :rtype: :class:`models.Model`
        :return: The newly created or updated protocol
        """
        existing_protocol_id = self.get_protocol_id_by_name(protocol.name, include_archived=True)
        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

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

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

        :type protocol_id: :class:`str`
        :param protocol_id: ID of the protocol to update

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

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

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

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: Limits the models to those in the specified projects

        :rtype: :class:`list` of :class:`models.Model`
        :return: The list of models in the specified projects
        """
        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'])

    @_enforce_string_ids('column_ids')
    def get_models_by_column_id(self, column_ids):
        """
        Retrieves a list of models by column ID

        :type column_ids: :class:`list` of :class:`str`
        :param column_ids: Limits the models to those with specified addable column ids

        :rtype: :class:`list` of :class:`models.Model`
        :return: The list of models matching the specified column IDs
        """
        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']]

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

        :type model_id: :class:`str`
        :param model_id: ID of the model to retrieve

        :rtype: :class:`models.Model`
        :return: The specified 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 ID.

        **NOTE**: This function returns the model ID as an int. It is best practice to cast this ID to a string before
        passing it back to LiveDesign. In a future release of LDClient, this function will return the ID as a string.

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

        :rtype: :class:`int`
        :return: Model ID

        :raises: :class:`RuntimeError`
        """
        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: :class:`str`
        :param model_name: Name of the model to retrieve

        :rtype: :class:`models.Model`
        :return: The specified model

        :raises: :class:`RuntimeError`
        """
        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. If a model with the same name already exists, it will be updated. Otherwise, it will be created.

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

        :rtype: :class:`models.Model`
        :return: The newly created or updated model

        :raises: :class:`RuntimeError`
        """
        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

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

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

        :type model_id: :class:`str`
        :param model_id: ID of the model to update

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

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

    @_enforce_string_ids('model_id', 'column_id', 'live_report_ids', 'entity_ids')
    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.

        :type model_id: :class:`str`
        :param model_id: The model id to be operated on

        :type column_id: :class:`str`
        :param column_id: The column id to be operated on

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

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

        :type failed_only: :class:`bool`
        :param failed_only: Only re-run the models that have failed to produce results, defaults to False

        :rtype: :class:`dict`
        :return: The async task

        :raises: :class:`RuntimeError`
        """
        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)

    @_enforce_string_ids('model_id', 'column_id', 'live_report_ids', 'entity_ids')
    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

        :type model_id: :class:`str`
        :param model_id: The model id to be operated on

        :type column_id: :class:`str`
        :param column_id: The column id to be operated on

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

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

        :type failed_only: :class:`bool`
        :param failed_only: Only re-run the models that have failed to produce results, defaults to False

        :type rerun_models: :class:`bool`
        :param rerun_models: Rerun models immediately after result deletion, defaults to False

        :rtype: :class:`dict`
        :return: The async task

        :raises: :class:`RuntimeError`
        """
        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)

    @_enforce_string_ids('model_id')
    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: :class:`str`
        :param model_id: ID of the model to update

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

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

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

        :rtype: :class:`dict`
        :return: 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)

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

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: Limits the models to those in the specified projects

        :rtype: :class:`list` of :class:`models.Model`
        :return: List of models in the specified projects
        """
        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 drivers

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

    @_enforce_string_ids('async_driver_id')
    def async_driver(self, async_driver_id):
        """
        Retrieves a single async driver

        :type async_driver_id: :class:`str`
        :param async_driver_id: ID of the async driver to retrieve

        :rtype: :class:`models.ModelAsyncDriver`
        :return: The specified async driver
        """
        # 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 asyncdriver ID.

        **NOTE**: This function returns the async driver ID as an int. It is best practice to cast this ID to a string before
        passing it back to LiveDesign. In a future release of LDClient, this function will return the ID as a string.

        :type async_driver_name: :class:`str`
        :param async_driver_name: Name of the async driver to retrieve

        :rtype: :class:`int`
        :return: The ID of the specified async driver

        :raises: :class:`RuntimeError`
        """
        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: :class:`str`
        :param async_driver_name: Name of the async driver to retrieve

        :rtype: :class:`models.ModelAsyncDriver`
        :return: The specified async driver

        :raises: :class:`RuntimeError`
        """
        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 an async driver. If an async driver with the same name already exists, it will be updated.
        Otherwise, it will be created.

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

        :rtype: :class:`models.ModelAsyncDriver`
        :return: The newly created or updated async driver

        :raises: :class:`RuntimeError`
        """
        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 an async driver

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

        :rtype: :class:`models.ModelAsyncDriver`
        :return: The newly created async driver
        """
        response = self.client.post(service_path=ASYNC_DRIVER_PATH, path="", json_data=async_driver)
        new_driver = ModelAsyncDriver.from_dict(response)
        return new_driver

    @_enforce_string_ids('async_driver_id')
    def update_async_driver(self, async_driver_id, async_driver):
        """
        Updates an existing async driver

        :type async_driver_id: :class:`str`
        :param async_driver_id: ID of the async driver to update

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

        :rtype: :class:`models.ModelAsyncDriver`
        :return: The updated async driver
        """
        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 model_spec: :class:`models.Model`
        :param model_spec: Model to quicktest

        :rtype: :class:`models.QuickTestModel`
        :return: The quicktest model launched
        """
        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

        :type database_name: :class:`str`
        :param database_name: Name of the database

        :rtype: :class:`str`
        :return: JSON object of settings
        """
        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)

    @_enforce_string_ids('live_report_id')
    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<./tutorials/assay_and_pose_import_tutorial>`.

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

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

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

        :type mapping_file_sha1: :class:`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: :class:`str`
        :param three_d_file_name: Path to the 3d data zip archive containing
            3D data to import.

        :type three_d_file_sha1: :class:`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 :class:`str`
        :param project: Reference to the project, either using
            the object or the project name, in which the new Live Report
            will be created.
            Even if not creating a new Live Report, a valid input
            must be provided.

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

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

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

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

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

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

        :type compound_source: :class:`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".

        :rtype: :class:`str`
        :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']

    @deprecated(replacement="load_assay_and_pose_data")
    @_enforce_string_ids('live_report_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.

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

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

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

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

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

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

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

        :type properties: :class:`list` of :class:`dict`
        :param properties: List of dicts with the properties to be exported

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

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

        :type compound_source: :class:`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"

        :rtype: :class:`str`
        :return: the task_id of the import upload task
        """

        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']

    @_enforce_string_ids('task_id')
    def wait_and_get_result_url(self, task_id, timeout=1200):
        """
        Gets the result URL of an async task

        :type task_id: :class:`str`
        :param task_id: ID of the async task

        :type timeout: :class:`int`
        :param timeout: Wait for the elapsed timeout time, in seconds, before raising an exception. Default 1200 seconds

        :rtype: :class:`str`
        :return: The result URL

        :raises: :class:`exceptions.AsyncServiceTaskFailedError`, :class:`Exception`
        """
        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):
        """
        Get the task result from the given URL

        :type result_url: :class:`str`
        :param result_url: The URL from which to fetch the result

        :rtype: :class:`dict`
        :return: The task result
        """
        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

        :type database_name: :class: `str`
        :param database_name: Name of the database

        :type generate_corp_ids: :class:`bool`
        :param generate_corp_ids: Whether to auto generate corporate IDs

        :rtype: :class:`str`
        :return: JSON object of settings
        """
        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)

    @_enforce_string_ids('live_report_id')
    def load_csv(self,
                 live_report_id,
                 filename,
                 project_name="Global",
                 published=False,
                 columns_only=False,
                 compounds_only=True,
                 identifier="SMILES"):
        """
        Load compounds into LiveDesign via csv.  If importing properties (`columns_only` is True or `compounds_only` is False), this will add the relevant columns to the Live Report.

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to load the data to

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

        :type project_name: :class:`str`
        :param project_name: Name of the project containing the Live Report

        :type published: :class:`bool`
        :param published: Whether to add the data to the given Live Report only (default) or to make available
                        for other Live Reports.

        :type columns_only: :class:`bool`
        :param columns_only: Whether to add the properties only or to add compounds as well (default). NOTE: cannot be True if `compounds_only` is True

        :type compounds_only: :class:`bool`
        :param compounds_only: Register the compounds (default) or add the compounds and columns/properties.  NOTE: cannot be True if `columns_only` is True

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

        :rtype: :class:`list` of :class:`dict`
        :return: List of objects representing compounds from the original project table - as dicts mirroring the
            structure of :class:`models.ImportResponse` objects.
        """

        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: :class:`str`
        :param project_name: Name of the project to put the compounds in

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

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

        :type published: :class:`bool`
        :param published: Whether to add the data to the given Live Report only (default) or to make available
                for other Live Reports.

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

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

        :type compound_source: :class:`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"


        :rtype: :class:`list` of :class:`dict`
        :return: A list of objects representing compounds from the original project table - as dicts mirroring the
            structure of :class:`models.ImportResponse` objects.
        """
        # 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.')

    @_enforce_string_ids('live_report_id')
    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: :class:`str`
        :param project_name: Name of the project to put the compounds in

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

        :type column_identifier: :class:`str`
        :param column_identifier: The column name in the CSV file header that represents either the Corporate ID if
            importing by ID, or SMILES if importing by SMILES.

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

        :type published: :class:`bool`
        :param published: Whether to add the data to the given Live Report only (default) or to make available
                        for other Live Reports.

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to load the data to

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

        :rtype: :class:`list` of :class:`str`
        :return: List of corporate IDs

        :raises: :class:`Exception`
        """
        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']]

    @_enforce_string_ids('live_report_id')
    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 synchronously

        :type live_report_id: :class:`str`
        :param live_report_id: The Live Report to add the compounds to

        :type filename: :class:`str`
        :param filename: Name of the sd file to load

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

        :type compounds_only: :class:`bool`
        :param compounds_only: If True, the non-structure data of the SDF will be ignored. If False, the properties will
            be imported as well. This will automatically add the relevant columns to the Live Report.

        :type compound_source: :class:`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: :class:`bool`
        :param published: Whether to add the data to the given Live Report only (default) or to make available
                        for other Live Reports.

        :rtype: :class:`list` of :class:`dict`
        :return: A list of objects representing compounds from the original project table - as dicts mirroring the
            structure of :class:`models.ImportResponse` objects.

        :raises: :class:`exceptions.AsyncServiceTaskFailedError`, :class:`Exception`
        """
        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']

    @_enforce_string_ids('live_report_id')
    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 asynchronously

        :type live_report_id: :class:`str`
        :param live_report_id: The Live Report to add the compounds too

        :type filename: :class:`str`
        :param filename: Name of the sd file to load

        :type published: :class:`bool`
        :param published: Whether to add the data to the given Live Report only (default) or to make available
                        for other Live Reports.

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

        :type compounds_only: :class:`bool`
        :param compounds_only: If True, the non-structure data of the SDF will be ignored. If False, the properties will
            be imported as well. This will automatically add the relevant columns to the Live Report.

        :type file_content: :class:`bytes`
        :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: :class:`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"

        :rtype: :class:`dict`
        :return: The response from LiveDesign, including 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 load_formulations_json(self,
                               live_report_id,
                               filename,
                               project_name="Global",
                               entities_only=False):
        """
        Import formulations into Live Design via json asynchronously

        :type live_report_id: :class:`str`
        :param live_report_id: The Live Report to add the formulations to

        :type filename: :class:`str`
        :param filename: Name of the json file to load

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

        :type entities_only: :class:`bool`
        :param entities_only: Whether only entities, and not columns, should be imported

        :rtype: :class:`list` of :class:`models.ImportResponse`
        :return: A list of objects representing the imported formulations

        :raises: :class:`exceptions.AsyncServiceTaskFailedError`, :class:`Exception`
        """
        response = self.load_formulations_json_async(
            live_report_id=live_report_id,
            filename=filename,
            project_name=project_name,
            entities_only=entities_only)
        result_url = self.wait_and_get_result_url(response['task_id'])
        results = self.get_task_result(result_url)

        return ImportResponse.from_list(results['import_responses'])

    def load_formulations_json_async(self,
                                     live_report_id,
                                     filename,
                                     project_name="Global",
                                     entities_only=False,
                                     file_content=None):
        """
        Import formulations into Live Design via json asynchronously

        :type live_report_id: :class:`str`
        :param live_report_id: The Live Report to add the formulations to

        :type filename: :class:`str`
        :param filename: Name of the json file to load

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

        :type entities_only: :class:`bool`
        :param entities_only: Whether only entities, and not columns, should be imported

        :type file_content: :class:`bytes`
        :param file_content: the content of the JSON file to load. If none is specified, attempt to get the file content
            using filename and the filesystem

        :rtype: :class:`dict`
        :return: The response from LiveDesign, including the task_id of the import upload task
        """
        import_settings = {
            "identifier": None,
            "type": "JSON_OBJECT_PER_FORMULATION",
            "project_name": project_name,
            "source_live_report_id": live_report_id,
            "published": True,
            "new_live_report": False,
            "compounds_only": entities_only,
            "add_data_to_live_report": True
        }

        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 json 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 ID.

        **NOTE**: This function returns the project ID as an int. It is best practice to cast this ID to a string before
        passing it back to LiveDesign. In a future release of LDClient, this function will return the ID as a string.

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

        :rtype: :class:`int` or :class:`list`
        :return: ID of the matching project, or an empty list if no matching project is found.
        """
        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.

        :rtype: :class:`list` of :class:`dict`
        :return: List of all :class:`models.RawGroupPermission` as dicts
        """
        return self.client.get(PERMISSIONS_PATH, '')

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

        :rtype: :class:`list` of :class:`dict`
        :return: List of all :class:`models.RawGroupMembership` as 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

        :rtype: :class:`str`
        :return: ID of the newly created group
        """
        data = {'name': name}
        response = self.client.post(GROUPS_PATH, '', json_data=data)
        return response['id']

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

        :type group_id: :class:`str`
        :param group_id: ID of the group to update

        :type group: :class:`dict`
        :param group: Group object to update

        :rtype: :class:`dict`
        :return: The updated :class:`models.RawGroup` as a dict
        """
        response = self.client.put(
            service_path=GROUPS_PATH, path='/{0}'.format(group_id), json_data=group)
        return response

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

        :type group_id: :class:`str`
        :param group_id: Group's ID

        :type user_id: :class:`str`
        :param user_id: User's ID

        :rtype: :class:`dict`
        :return: The newly created :class:`models.RawGroupMembership` as a dict
        """
        data = {'group_id': group_id, 'user_id': user_id}
        return self.client.post(GROUP_MEMBERSHIP_PATH, '', json_data=data)

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

        :type group_id: :class:`str`
        :param group_id: Group's ID

        :type user_id: :class:`str`
        :param user_id: User's ID

        :rtype: :class:`NoneType`
        :return: None
        """
        data = {'group_id': group_id, 'user_id': user_id}
        self.client.delete(GROUP_MEMBERSHIP_PATH, '', json_data=data)

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

        :type group_id: :class:`str`
        :param group_id: Group's ID

        :type project_id: :class:`str`
        :param project_id: Project's ID

        :rtype: :class:`dict`
        :return: Newly created :class:`models.RawGroupPermission` as a dict
        """
        data = {'group_id': group_id, 'project_id': project_id}
        return self.client.post(PERMISSIONS_PATH, '', json_data=data)

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

        :type group_id: :class:`str`
        :param group_id: Group's ID

        :type project_id: :class:`str`
        :param project_id: Project's ID

        :rtype: :class:`NoneType`
        :return: None
        """
        data = {'group_id': group_id, 'project_id': project_id}
        self.client.delete(PERMISSIONS_PATH, '', json_data=data)

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

        :rtype: :class:`list` of :class:`dict`
        :return: List of :class:`models.RawGroup` objects as dicts
        """
        response = self.client.get(GROUPS_PATH, '')
        return response

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

        :type group_id: :class:`str`
        :param group_id: ID of the group to retrieve

        :rtype: :class:`dict`
        :return: The specified :class:`models.RawGroup` as a dict
        """
        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

        :rtype: :class:`list` of :class:`models.Project`
        :return: List of all projects
        """
        response = self.client.get(PROJECTS_PATH, '')

        return Project.from_list(response)

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

        :rtype: :class:`list` of :class:`models.Project`
        :return: List of all projects
        """
        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 get_accessible_project_for_non_admin_users(self, usernames):
        """
        Returns a list of username and their corresponding
        explicitly assigned projects ids and unrestricted projects ids
        (implicitly assigned projects like projects for admin users are not included)
        Check definition of :class:`models.UsernameToProjectIds` for details

        :type usernames: :class:`list` of :class:`str`
        :param usernames: List of usernames for which we want the accessible project ids

        :rtype: :class:`list` of :class:`models.UsernameToProjectIds`
        :return: List of username and their corresponding accessible project ids
        """
        query = {
            'search_type': 'accessible_project_search_for_non_admin_user',
            'accessible_project_search_query': {
                'usernames': usernames
            }
        }
        response = self.client.post(PROJECTS_PATH, '/search', json_data=query)
        return UsernameToProjectIds.from_list(response['username_to_project_ids_list'])

    def create_or_update_project(self, project):
        """
        Creates or updates a project. If a project with the same ID already exists, it is updated. Otherwise, it is created.

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

        :rtype: :class:`models.Project`
        :return: The newly created or updated project
        """
        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

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

    @_enforce_string_ids('project_id')
    def update_project(self, project_id, project):
        """
        Updates an existing project

        :type project_id: :class:`str`
        :param project_id: ID of the project to update

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

        :rtype: :class:`models.Project`
        :return: The updated project
        """
        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: :class:`list` of :class:`dict`
        :param sources: Specifies the 3D models that should be exported.
                Each dict should have the following keys:

                - `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 Live Report 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: :class:`str`
        :param file_format: The format of the 3D model file to be exported; either 'pse' or 'mol2'

        :rtype: :class:`str`
        :return: The ID of the requested export task

        :raises: :class:`RuntimeError`
        """
        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']

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

        :type task_id: :class:`str`
        :param task_id: The ID of an export task, as returned by :meth:`export_xtals`

        :rtype: :class:`str`
        :return: The current status of the xtals export task:

            - "new"
            - "submitted"
            - "running"
            - "running_stage2
            - "finished"
            - "importing"
            - "imported"
            - "callback_finished"
            - "failed"
            - "malformed_output"
            - "killed"

        :raises: :class:`RuntimeError`
        """
        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 :meth:`export_xtals_status` returns
        a status code of "ready".

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

        :rtype: :class:`bytes`
        :return: The content of the exported 3D model file

        :raises: :class:`RuntimeError`, :class:`HTTPError`
        """
        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

    @_enforce_string_ids('project_ids', 'alternate_id')
    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 exists, or upload an attachment and
        persist it in LiveDesign

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

        :type file_type: :class:`str`
        :param file_type: type of file. Valid values:

            - 'IMAGE'
            - 'ATTACHMENT'
            - 'THREE_D'

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: projects the file should have ACLs on

        :type file_obj: :class:`bytes`
        :param file_obj: File Object (used instead of `attachment_file_name`)

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

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

        :rtype: :class:`dict`
        :return: the content of the persisted :class:`models.AttachmentMetadata`, as a 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'})

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

        :type alternate_id: :class:`str`
        :param alternate_id: alternate ID of the attachment to retrieve

        :rtype: :class:`bytes`
        :return: The specified attachment

        :raises: :class:`HTTPError`
        """
        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

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

        :type alternate_ids: :class:`list` of :class:`str`
        :param alternate_ids: collection of alternate IDs of the attachments
            to retrieve

        :rtype: :class:`list` of :class:`dict`
        :return: A list of attachments. Each dictionary mirrors the fields in :class:`models.AttachmentMetadata`.
        """
        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']

    @_enforce_string_ids('column_id', 'ligand_attachment_ids', 'ligand_entity_id', 'project_id',
                         'protein_attachment_ids')
    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 :meth:`create_attachment`

        :type column_id: :class:`str`
        :param column_id: column to put pose data on

        :type ligand_attachment_ids: :class:`list` of :class:`str`
        :param ligand_attachment_ids: ligand data on the pose (created via create_attachment)

        :type ligand_entity_id: :class:`str`
        :param ligand_entity_id: entity id of the compound to put the pose data on

        :type project_id: :class:`str`
        :param project_id: ACL on the pose

        :type published: :class:`bool`
        :param published: whether or not the created pose should be published

        :type protein_attachment_ids: :class:`list` of :class:`str`
        :param protein_attachment_ids: protein data on the pose (created via create_attachment)

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

        :rtype: :class:`dict`
        :return: the content of the created pose, in the form of a :class:`models.Complex` object as a 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)
            all_attachment_ids = list(all_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)

    @_enforce_string_ids('attachment_ids')
    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 attachment_ids: :class:`list` of :class:`str`
        :param attachment_ids: collection of attachment IDs of which to infer the shared file type

        :rtype: :class:`str`
        :return: the shared file extension, if it exists; otherwise None
        """
        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

    @_enforce_string_ids('live_report_id')
    def pose_search(self, live_report_id):
        """
        Returns all pose data contained in a Live Report.

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

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report to retrieve pose data from.

        :rtype: :class:`list` of :class:`dict`
        :return: all poses in a Live Report. Dicts mirror the structure of :class:`models.Complex` objects.
        """
        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

        :type columns: :class:`list` of :class:`models.Column`
        :param columns: List of columns to update

        :rtype: :class:`list` of :class:`dict`
        :return: List of updated :class:`models.Column` as dicts
        """

        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

        :type properties: :class:`dict`
        :param properties: A dictionary of key -> value

        :rtype: :class:`dict`
        :return: Updated properties dict mapping property key to property value.
        """

        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 Database Constants

        :rtype: :class:`NoneType`
        :return: None
        """
        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 asynchronously calculate Live Reports.
        This will only invalidate Live Reports that have been recently edited, as determined by the ACTIVE_LIVE_REPORT_REFRESH_TIME property.

        :rtype: :class:`dict`
        :return: A :class:`models.NightlyTaskExecuteRequest` as a dict.
        """
        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.
        This will only invalidate Live Reports that have been recently edited, as determined by the ACTIVE_LIVE_REPORT_REFRESH_TIME property.
        Note: To invalidate AND recalculate Live Reports, you can use :meth:`refresh_live_reports`

        :rtype: :class:`dict`
        :return: A :class:`models.NightlyTaskExecuteRequest` as a dict.
        """
        data = {"task_type": "cache_invalidate"}
        return self.client.post(service_path=NIGHTLY_TASK, path='/', json_data=data)

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

        :type live_report_ids: :class:`list` of :class:`str`
        :param live_report_ids: IDs of Live Reports to invalidate

        :rtype: :class:`dict`
        :return: The :class:`models.InvalidateLiveReportCacheRequest` sent to the LiveDesign server as a dict.
        """
        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)

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

        :type entity_ids: :class:`list` of :class:`str`
        :param entity_ids: (Numeric) Entity IDs to invalidate

        :rtype: :class:`dict`
        :return: The :class:`models.InvalidateLiveReportCacheRequest` sent to the LiveDesign server as a dict.
        """
        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

        :type changed_observations: :class:`list` of :class:`models.ChangedObservation`
        :param changed_observations: Collection of changed observations

        :rtype: :class:`dict`
        :return: The :class:`models.InvalidateLiveReportCacheRequest` sent to the LiveDesign server as a dict.
        """
        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.  Calling this function removes the input rows.

        :type removed_rows: :class:`list` of :class:`models.RemovedLiveReportRow`
        :param removed_rows: collection of Live Report + entity ID pairs removed

        :rtype: :class:`list`
        :return: The :class:`models.InvalidateLiveReportCacheRequest` sent to the LiveDesign server as a dict.
        """
        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.

        :rtype: :class:`str`
        :return: CSRF token

        :raises: :class:`HTTPError`
        """
        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

        :rtype: :class:`list` of :class:`dict`
        :return: List of :class:`models.Property` as dicts.
        """
        return self.client.get(service_path=CONFIG_PATH, path='')

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

        :rtype: :class:`list` of :class:`dict`
        :return: A list of :class:`models.MethodTimingStats` as dicts.
        """
        return self.client.get(service_path=TIMINGS_PATH, path='')

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

        :rtype: :class:`NoneType`
        :return: None
        """
        self.client.get(service_path=CLEAR_TIMINGS_PATH, path='')

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

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

        :rtype: :class:`dict`
        :return: Response from LiveDesign
        """
        return self.client.get(service_path=path, path='')

    @deprecated(replacement="replace_column_async")
    @_enforce_string_ids('old_column_id', 'new_column_id', 'project_ids')
    def replace_column(self, old_column_id, new_column_id, project_ids):
        """
        Replace a column with another column (in all Live Reports, plots, MPOs, Formulas, adv search queries, column descriptors,
        sorted columns, frozen columns, model dependent on columns, etc) in all projects specified

        **NOTE**: This function returns the Live Report IDs as a collection of ints. It is best practice to cast these
        IDs to strings before passing it back to LiveDesign. In a future release of LDClient, this function will return
        the ID as a string.

        :type old_column_id: :class:`str`
        :param old_column_id: the column ID to be replaced

        :type new_column_id: :class:`str`
        :param new_column_id: the new column ID to be used

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: list of project IDs where the replace should occur

        :rtype: :class:`list` of :class:`int`
        :return: collection of Live Report 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)

    @_enforce_string_ids('old_column_id', 'new_column_id', 'project_ids')
    def replace_column_async(self, old_column_id, new_column_id, project_ids):
        """
        Replace a column with another column (in all Live Reports, plots, MPOs, Formulas, adv search queries, column descriptors,
        sorted columns, frozen columns, model references to columns, etc) in all projects specified. Functions
        :meth:`wait_and_get_result_url` and :meth:`get_task_result` should be used after this function call to
        verify column replacement success. `task_id` can be found in the dictionary returned by this function
        and `result_url` is returned by :meth:`wait_and_get_result_url`.

        :type old_column_id: :class:`str`
        :param old_column_id: the column ID to be replaced

        :type new_column_id: :class:`str`
        :param new_column_id: the new column ID to be used

        :type project_ids: :class:`list` of :class:`str`
        :param project_ids: list of project IDs where the replace should occur

        :rtype: :class:`dict`
        :return: The replace column request sent to the LiveDesign server
        """
        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/async', json_data=data)

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

        :rtype: :class:`list` of :class:`models.DatabaseColumn`
        :return: List of database columns
        """
        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 database_column: :class:`models.DatabaseColumn`
        :param database_column: Database Column To update

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

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

        :type project_id: :class:`str`
        :param project_id: (ignored)

        :rtype: :class:`list` of :class:`models.FreeformColumn`
        :return: List of all freeform columns
        """

        if project_id is not None:
            warnings.warn(
                'In the LD Client function freeform_columns, the project_id '
                'parameter is deprecated, is currently ignored, and will soon be removed from ldclient.',
                FutureWarning)
        response = self.client.get(service_path=FREEFORM_COLUMNS_PATH, path='')
        return FreeformColumn.from_list(response)

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

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

        :rtype: :class:`models.FreeformColumn`
        :return: The specified freeform column
        """

        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 on the LiveDesign server from the input :class:`models.FreeformColumn` object.

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

        :rtype: :class:`models.FreeformColumn`
        :return: The newly created freeform column
        """
        response = self.client.post(
            service_path=FREEFORM_COLUMNS_PATH, path='', json_data=freeform_column)

        freeform_column = FreeformColumn.from_dict(response)

        return freeform_column

    @_enforce_string_ids('addable_column_ids', 'entity_ids', 'project_id', 'live_report_id')
    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: :class:`list` of :class:`str`
        :param addable_column_ids: the column ids from which observations will be returned

        :type entity_ids: :class:`list` of :class:`str`
        :param entity_ids: list of entity ids to query

        :type project_id: :class:`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: :class:`str`
        :param live_report_id: live report id

        :rtype: :class:`list` of :class:`models.Observation`
        :return: A list of the specified observations
        """
        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):
        """
        Sets the values of cells in a freeform column.  The input observations represent entity IDs and their
        corresponding values for a freeform column.

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

        :rtype: :class:`list` of :class:`models.Observation`
        :return: The newly added freeform column cell values
        """

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

        return Observation.from_list(response)

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

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

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

        :rtype: :class:`models.CompoundLink`
        :return: The newly created compound link
        """
        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)

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

        :type link_id: :class:`str`
        :param link_id: Link id of the compound link.

        :rtype: :class:`NoneType`
        :return: None
        """
        self.client.delete(service_path=COMPOUNDS_LINK_PATH, path='/{}'.format(link_id))

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

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

        :rtype: :class:`list` of :class:`models.CompoundLink`
        :return: List of compound links on specified real compound
        """
        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', []))

    @_enforce_string_ids('live_report_id', 'addable_column_ids', 'entity_ids')
    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

        :type live_report_id: :class:`str`
        :param live_report_id: Live Report ID to export

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

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

        :rtype: :class:`str`
        :return: The ID of the requested export task

        :raises: :class:`RuntimeError`
        """
        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']

    @_enforce_string_ids('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: :class:`str`
        :param task_id: The ID of an export task, as returned by :meth:`export_to_maestro`

        :rtype: :class:`str`
        :return: The attachment id of the file, or None if it's not available yet

        :raises: :class:`RuntimeError`, :class:`exceptions.AsyncServiceTaskFailedError`, :class:`Exception`
        """
        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

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

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

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

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

        :rtype: :class:`bytes`
        :return: The contents of the maestro file

        :raises: :class:`RuntimeError`, :class:`exceptions.AsyncServiceTaskFailedError`, :class:`Exception`, :class:`HTTPError`
        """
        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)

    @_enforce_string_ids('project_id', 'live_report_id')
    def column_aliases(self, project_id, live_report_id=None):
        """
        Retrieves all column aliases in the specified Project (required) & Live Report (optional)

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

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report in which column aliases are to be searched

        :rtype: :class:`list` of :class:`models.ColumnAlias`
        :return: List of column aliases specified
        """
        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'))

    @_enforce_string_ids('live_report_id')
    def get_column_groups_by_live_report_id(self, live_report_id):
        """
        Retrieves the column groups (in order) for a given Live Report.
        NOTE: An "ungrouped column" is represented by a single-member column group.

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report for which to retrieve column groups

        :rtype: :class:`list` of :class:`models.ColumnGroup`
        :return: An ordered list of the column groups in the specified Live Report
        """
        response = self.client.get(
            service_path=LIVE_REPORTS_PATH,
            path='/{0}/results/metadata'.format(live_report_id),
            params={'report_level': 'PARENT'})
        column_groups = response['column_groups']['SPREADSHEET']
        return [ColumnGroup.from_dict(column_group) for column_group in column_groups]

    @_enforce_string_ids('live_report_id')
    def update_column_groups(self, live_report_id, column_groups, ungroup_mode=None):
        """
        Updates column groups for a given Live Report. If columns are left out of the request, the unspecified columns
        will be ungrouped & shifted to the right (end of the list)
        NOTE: It is highly recommended that you use :meth:`get_column_groups_by_live_report_id` & mutate the returned
        list in order to build the updated column groups parameter.

        :type live_report_id: :class:`str`
        :param live_report_id: ID of the Live Report for which to update column groups

        :type column_groups: :class:`list` of :class:`models.ColumnGroup`
        :param column_groups: Ordered list of the new column groups for the Live Report

        :type ungroup_mode: :class:`str`
        :param ungroup_mode: Ungroup Mode. Valid values:

        - None (default)
        - "KEEP": The limited columns from this group will remain in the Live Report as-is, but without the limiting condition in the column group.
        - "REMOVE": The limited columns from this group will be replaced with their parent columns in the Live Report.

        :rtype: :class:`NoneType`
        :return: None
        """
        self.client.put(
            service_path=COLUMN_GROUPS_PATH,
            path='/{0}'.format(live_report_id),
            json_data={
                'SPREADSHEET': [{
                    'ungroup_mode': ungroup_mode,
                    'column_group': column_group
                } for column_group in column_groups]
            })

    @_enforce_string_ids('project_id', 'live_report_id')
    def formulations_search(self, project_id=None, live_report_id=None):
        """
        Search for formulations in a project or a LiveReport.

        Either a project must be provided, or a LiveReport. Both cannot be provided.

        This method returns an iterator!

        :type project_id: :class:`str`
        :param project_id: the project ID to search in

        :type live_report_id: :class:`str`
        :param live_report_id: the LiveRepor ID to search in

        :rtype: :class:`iter` of :class:`models.FormulationData`
        :return: an iterator over the found formulation data
        """
        if project_id is not None:
            if live_report_id is not None:
                raise ValueError(
                    "For formulations search, either project or LiveReport must be specified")
            payload = {"project_id": project_id}
        else:
            if live_report_id is None:
                raise ValueError(
                    "For formulations search, both project and LiveReport cannot be specified")
            payload = {"live_report_id": live_report_id}

        for result in self._pagination_driver(
                service_path=FORMULATIONS_PATH,
                path="/all_formulations/search",
                base_payload=payload,
        ):
            yield FormulationData.from_dict(result)

    def _pagination_driver(self, service_path, path, base_payload, page_size=None):
        """
        Helper function for iterating over all of the results in an LD paginated endpoint.

        :type service_path: :class:`str`
        :param service_path: the service path

        :type path: :class:`str`
        :param path: the path within the service

        :type base_payload: :class:`dict`
        :param base_payload: the search payload. In each request, relevant pagination details will
            be added to this payload.

        :type page_size: :class:`int`
        :param page_size: the desired page size

        :rtype: :class:`iter` of :class:`dict`
        :return: iterator of all of the results. Note that HTTP requests are made on demand
            as the iteration proceeds.
        """
        page_details = {"page_size": page_size}
        while page_details is not None:
            # NOTE: shallow copy is good enough because we only edit the keys of the
            # root dictionary.
            payload = base_payload.copy()
            payload["page_details"] = page_details
            response = self.client.post(service_path=service_path, path=path, json_data=payload)
            page_details = response.get("next_page_details")
            for result in response["results"]:
                yield result

    @_enforce_string_ids('addable_column_id', 'project_id', 'live_report_id')
    def set_column_alias(self, addable_column_id, alias, project_id=None, live_report_id=None):
        """
        Sets column alias in the specified Project or LiveReport.

        :type addable_column_id: :class:`str`
        :param addable_column_id: Addable column ID to set the alias on

        :type alias: :class:`str`
        :param alias: Alias to set on this column

        :type project_id: :class:`str`
        :param project_id: Numeric ID of the project where the alias will be visible

        :type live_report_id: :class:`str`
        :param live_report_id: Numeric ID of the LiveReport where the alias will be visible

        :rtype: class:`models.ColumnAlias`
        :return: The newly set column alias

        :raises: :class:`ValueError`
        """
        if (live_report_id is None) == (project_id is None):
            raise ValueError("Exactly one of live_report_id, project_id must be specified.")
        column_alias = ColumnAlias(
            addable_column_id=addable_column_id,
            alias=alias,
            live_report_id=live_report_id,
            project_id=project_id)

        response = self.client.post(service_path=COLUMN_ALIAS_PATH, path='', json_data=column_alias)
        return ColumnAlias.from_dict(response)
