├── .codeclimate.yml ├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.rst ├── docs ├── AdminWebService.rst ├── SupervisorWebService.rst └── index.rst ├── five9 ├── __init__.py ├── environment.py ├── exceptions.py ├── five9.py ├── models │ ├── __init__.py │ ├── base_model.py │ ├── disposition.py │ ├── disposition_type_params.py │ ├── key_value_pair.py │ ├── timer.py │ └── web_connector.py └── tests │ ├── __init__.py │ ├── common.py │ ├── common_crud.py │ ├── test_api.py │ ├── test_base_model.py │ ├── test_disposition.py │ ├── test_environment.py │ ├── test_five9.py │ ├── test_key_value_pair.py │ ├── test_web_connector.py │ └── wsdl │ ├── AdminWebService.wsdl │ └── SupervisorWebService.wsdl ├── requirements.txt └── setup.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | JavaScript: false 3 | Python: true 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | 3 | branch = True 4 | 5 | [report] 6 | 7 | exclude_lines = 8 | pragma: no cover 9 | def __repr__ 10 | if self.debug: 11 | raise NotImplementedError 12 | if __name__ == .__main__.: 13 | 14 | ignore_errors = True 15 | 16 | include = 17 | */five9/* 18 | 19 | omit = 20 | */virtualenv/* 21 | */tests/* 22 | setup.py 23 | */__init__.py 24 | tests.py 25 | 26 | [xml] 27 | 28 | output = coverage.xml 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # PyCharm 92 | .idea/ 93 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.3" 6 | - "3.4" 7 | - "3.5" 8 | - "3.6" 9 | 10 | env: 11 | global: 12 | - PROJECT="Python Five9" 13 | - VERSION="0.1.0" 14 | - RELEASE="0.0.3" 15 | - secure: "W2o6D/4tFTxl7KxAxB8YacAGadRBB/VdTSIyam7e2K9iCiW6kNVPWsIjTZCbYQb6hRuuLwerrkFrxKNkHIlWa6YJxcrfVZBSAabMBgviAB3ZyfSyrxAc0qfxZGm/pSndX6rZCdtRGb/P7kgDR/iXzDiKCa1mkyXdPDhpPMTt+9Co1Es+HDTgx71f6hmChp57xsQucxETnElMVAnkM7AW4QSlZxuAIJT2g5Zs8+bADowltIzj0fLPAcOnPPVBqZWfz+6Jlw02ZLXfsynZAgQf+ucwVMvPyg0jKFcrxdBZb9bwIhSvs4bNZ74JTg0prJVhOg630010Zaiq4dlVfVovAtfG3uQFdRjYBhYQbLQcLdgTfaIN9v7GBvT6UwgPsA7a5z1vlrTbqA/w0ityFwDRxJMYBcpvh2op/X7jgctFp5a5LyfsNkBO7ky3HrX+/w3RtRk69AI7rV4jqjwy/DyTcSMT6OHMrmSY2plTtoUaNINKGHSeEaCkWIlJHOGfhuWJGtcT100p35dF3lZIxSLFFpLufD5iazLzHp5GCJnqgqPJJVS1Z8lqlWv7GG3QhHdExgGdO8ePI+VVv9stWXNuS43pOU6bhKq4K8sQd4Cyj4H5TPO8Idxi/Ug+waojdJ5xiPLZXXLQk7uafPbTVceP+qT/rs3qSgVXbN3ES/eU7w0=" 16 | matrix: 17 | - TESTS="1" 18 | - LINT_CHECK="1" 19 | - PYPI="1" 20 | - DOCS="1" 21 | 22 | install: 23 | - git clone --depth=1 https://github.com/LasLabs/python-quality-tools.git ${HOME}/python-quality-tools 24 | - export PATH=${HOME}/python-quality-tools/travis:${PATH} 25 | - travis_install 26 | 27 | script: 28 | - travis_run 29 | 30 | after_success: 31 | - travis_after_success 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright `2017` `LasLabs Inc.` 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |License MIT| | |PyPi Package| | |PyPi Versions| 2 | 3 | |Build Status| | |Test Coverage| | |Code Climate| 4 | 5 | ============ 6 | Python Five9 7 | ============ 8 | 9 | This library allows you to interact with the Five9 Settings and Statistics Web 10 | Services using Python. 11 | 12 | * `Read The API Documentation `_ 13 | 14 | Installation 15 | ============ 16 | 17 | Installation is easiest using Pip and PyPi:: 18 | 19 | pip install five9 20 | 21 | If you would like to contribute, or prefer Git:: 22 | 23 | git clone https://github.com/LasLabs/python-five9.git 24 | cd python-five9 25 | pip install -r requirements.txt 26 | pip install . 27 | 28 | Usage 29 | ===== 30 | 31 | Connect 32 | ------- 33 | 34 | .. code-block:: python 35 | 36 | from five9 import Five9 37 | 38 | client = Five9('user', 'password') 39 | 40 | Configuration Web Services 41 | -------------------------- 42 | 43 | Documentation: 44 | 45 | * `Five9 `_ 46 | * `API Docs `_ 47 | 48 | Example - Get All Skills: 49 | 50 | .. code-block:: python 51 | 52 | client.configuration.getSkills() 53 | # Returns 54 | [{ 55 | 'description': None, 56 | 'id': 266184L, 57 | 'messageOfTheDay': None, 58 | 'name': 'TestSkill', 59 | 'routeVoiceMails': False 60 | }] 61 | 62 | Example - Create a contact field to track modified time: 63 | 64 | .. code-block:: python 65 | 66 | client.configuration.createContactField({ 67 | 'name': 'modified_at', 68 | 'displayAs': 'Invisible', 69 | 'mapTo': 'LastModifiedDateTime', 70 | 'type': 'DATE_TIME', 71 | 'system': True, 72 | }) 73 | 74 | Example - Search for a contact by first and last name and get a list of ``dict``s 75 | representing the result: 76 | 77 | .. code-block:: python 78 | 79 | criteria = client.create_criteria({ 80 | 'first_name': 'Test', 81 | 'last_name': 'User', 82 | }) 83 | result = client.configuration.getContactRecords(criteria) 84 | # The above result is basically unusable. Parse into a list of dicts:: 85 | client.parse_response(result['fields'], result['records']) 86 | 87 | Example - Update a contact using their first and last name as the search keys: 88 | 89 | .. code-block:: python 90 | 91 | contact = { 92 | 'first_name': 'Test', 93 | 'last_name': 'User', 94 | 'city': 'Las Vegas', 95 | 'state': 'NV', 96 | 'number1': '1234567890', 97 | } 98 | mapping = client.create_mapping(contact, keys=['first_name', 'last_name']) 99 | client.configuration.updateCrmRecord( 100 | record={'fields': mapping['fields']}, 101 | crmUpdateSettings={ 102 | 'fieldsMapping': mapping['field_mappings'], 103 | 'skipHeaderLine': True, 104 | 'crmAddMode': 'DONT_ADD', 105 | 'crmUpdateMode': 'UPDATE_SOLE_MATCHES', 106 | } 107 | ) 108 | 109 | Statistics Web Services 110 | ----------------------- 111 | 112 | Documentation: 113 | 114 | * `Five9 `_ 115 | * `API Docs `_ 116 | 117 | A supervisor session is required in order to perform most actions provided in the 118 | Supervisor Web Service. Due to this, a session is implicitly created before the 119 | supervisor is used. 120 | 121 | The session is created with the following defaults. You can change the parameters 122 | by changing the proper instance variable on the `Five9` object: 123 | 124 | +----------------------+------------------------+---------------+ 125 | | Five9 Parameter | Instance Variable | Default | 126 | +======================+========================+===============+ 127 | | `forceLogoutSession` | `force_logout_session` | `True` | 128 | +----------------------+------------------------+---------------+ 129 | | `rollingPeriod` | `rolling_period` | `Minutes30` | 130 | +----------------------+------------------------+---------------+ 131 | | `statisticsRange` | `statistics_range` | `CurrentWeek` | 132 | +----------------------+------------------------+---------------+ 133 | | `shiftStart` | `shift_start_hour` | `8` | 134 | +----------------------+------------------------+---------------+ 135 | | `timeZone` | `time_zone_offset` | `-7` | 136 | +----------------------+------------------------+---------------+ 137 | 138 | Example Use: 139 | 140 | .. code-block:: python 141 | 142 | # Setup a session - required for most things 143 | client.supervisor.getUserLimits() 144 | # Returns 145 | { 146 | 'mobileLimit': 0L, 147 | 'mobileLoggedin': 0L, 148 | 'supervisorLimit': 1L, 149 | 'supervisorsLoggedin': 1L 150 | } 151 | 152 | Known Issues / Roadmap 153 | ====================== 154 | 155 | * The supervisor session options should be represented in a class and documented, 156 | instead of the mostly undocumented free-form dictionary mapped to instance 157 | variables. 158 | 159 | Credits 160 | ======= 161 | 162 | Images 163 | ------ 164 | 165 | * LasLabs: `Icon `_. 166 | 167 | Contributors 168 | ------------ 169 | 170 | * Dave Lasley 171 | 172 | Maintainer 173 | ---------- 174 | 175 | .. image:: https://laslabs.com/logo.png 176 | :alt: LasLabs Inc. 177 | :target: https://laslabs.com 178 | 179 | This module is maintained by LasLabs Inc. 180 | 181 | .. |Build Status| image:: https://img.shields.io/travis/LasLabs/python-five9/master.svg 182 | :target: https://travis-ci.org/LasLabs/python-five9 183 | .. |Test Coverage| image:: https://img.shields.io/codecov/c/github/LasLabs/python-five9/master.svg 184 | :target: https://codecov.io/gh/LasLabs/python-five9 185 | .. |Code Climate| image:: https://img.shields.io/codeclimate/github/LasLabs/python-five9.svg 186 | :target: https://codeclimate.com/github/LasLabs/python-five9 187 | .. |License MIT| image:: https://img.shields.io/github/license/laslabs/python-five9.svg 188 | :target: https://opensource.org/licenses/MIT 189 | :alt: License: MIT 190 | .. |PyPi Package| image:: https://img.shields.io/pypi/v/five9.svg 191 | :target: https://pypi.python.org/pypi/five9 192 | :alt: PyPi Package 193 | .. |PyPi Versions| image:: https://img.shields.io/pypi/pyversions/five9.svg 194 | :target: https://pypi.python.org/pypi/five9 195 | :alt: PyPi Versions 196 | -------------------------------------------------------------------------------- /docs/AdminWebService.rst: -------------------------------------------------------------------------------- 1 | Admin Web Service 2 | ================= 3 | 4 | .. autowsdl:: ../five9/tests/wsdl/AdminWebService.wsdl 5 | :namespace: AdminWebService 6 | -------------------------------------------------------------------------------- /docs/SupervisorWebService.rst: -------------------------------------------------------------------------------- 1 | Supervisor Web Service 2 | ====================== 3 | 4 | .. autowsdl:: ../five9/tests/wsdl/SupervisorWebService.wsdl 5 | :namespace: SupervisorWebService 6 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. python-five9 documentation master file, created by 2 | sphinx-quickstart on Mon Sep 4 22:29:35 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to python-five9's documentation! 7 | ======================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 4 11 | :caption: Contents: 12 | 13 | five9 14 | AdminWebService 15 | SupervisorWebService 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /five9/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | from .five9 import Five9 6 | from . import models 7 | 8 | __all__ = [ 9 | 'Five9', 10 | 'models', 11 | ] 12 | -------------------------------------------------------------------------------- /five9/environment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | from .exceptions import ValidationError 6 | 7 | 8 | class Api(object): 9 | """This is a set of decorators for model validators.""" 10 | 11 | @staticmethod 12 | def model(method): 13 | """Use this to decorate methods that expect a model.""" 14 | def wrapper(self, *args, **kwargs): 15 | if self.__model__ is None: 16 | raise ValidationError( 17 | 'You cannot perform CRUD operations without selecting a ' 18 | 'model first.', 19 | ) 20 | return method(self, *args, **kwargs) 21 | return wrapper 22 | 23 | @staticmethod 24 | def recordset(method): 25 | """Use this to decorate methods that expect a record set.""" 26 | def wrapper(self, *args, **kwargs): 27 | if self.__records__ is None: 28 | raise ValidationError( 29 | 'There are no records in the set.', 30 | ) 31 | return method(self, *args, **kwargs) 32 | return Api.model(wrapper) 33 | 34 | 35 | class Environment(object): 36 | """Represents a container for models with a back-reference to Five9. 37 | """ 38 | 39 | # The authenticated ``five9.Five9`` object. 40 | __five9__ = None 41 | # A dictionary of models, keyed by class name. 42 | __models__ = None 43 | # The currently selected model. 44 | __model__ = None 45 | # A list of records represented by this environment. 46 | __records__ = None 47 | # The current record represented by this environment. 48 | __record__ = None 49 | 50 | @classmethod 51 | def __new__(cls, *args, **kwargs): 52 | """Find and cache all model objects, if not already done.""" 53 | if cls.__models__ is None: 54 | models = __import__('five9').models 55 | cls.__models__ = { 56 | model: getattr(models, model) 57 | for model in models.__all__ 58 | if not model.startswith('_') 59 | } 60 | return object.__new__(cls) 61 | 62 | def __init__(self, five9, model=None, records=None): 63 | """Instantiate a new environment.""" 64 | self.__five9__ = five9 65 | self.__model__ = model 66 | self.__records__ = records 67 | 68 | def __getattribute__(self, item): 69 | try: 70 | return super(Environment, self).__getattribute__(item) 71 | except AttributeError: 72 | return self.__class__(self.__five9__, self.__models__[item]) 73 | 74 | @Api.recordset 75 | def __iter__(self): 76 | """Pass iteration through to the records. 77 | 78 | Yields: 79 | BaseModel: The next record in the iterator. 80 | 81 | Raises: 82 | StopIterationError: When all records have been iterated. 83 | """ 84 | for record in self.__records__: 85 | self.__record__ = record 86 | yield record 87 | raise StopIteration() 88 | 89 | @Api.model 90 | def create(self, data, refresh=False): 91 | """Create the data on the remote, optionally refreshing.""" 92 | self.__model__.create(self.__five9__, data) 93 | if refresh: 94 | return self.read(data[self.__model__.__name__]) 95 | else: 96 | return self.new(data) 97 | 98 | @Api.model 99 | def new(self, data): 100 | """Create a new memory record, but do not create on the remote.""" 101 | data = self.__model__._get_non_empty_dict(data) 102 | return self.__class__( 103 | self.__five9__, 104 | self.__model__, 105 | records=[self.__model__.deserialize(data)], 106 | ) 107 | 108 | @Api.model 109 | def read(self, external_id): 110 | """Perform a lookup on the current model for the provided external ID. 111 | """ 112 | return self.__model__.read(self.__five9__, external_id) 113 | 114 | @Api.recordset 115 | def write(self): 116 | """Write the records to the remote.""" 117 | return self._iter_call('write') 118 | 119 | @Api.recordset 120 | def delete(self): 121 | """Delete the records from the remote.""" 122 | return self._iter_call('delete') 123 | 124 | @Api.model 125 | def search(self, filters): 126 | """Search Five9 given a filter. 127 | 128 | Args: 129 | filters (dict): A dictionary of search strings, keyed by the name 130 | of the field to search. 131 | 132 | Returns: 133 | Environment: An environment representing the recordset. 134 | """ 135 | records = self.__model__.search(self.__five9__, filters) 136 | return self.__class__( 137 | self.__five9__, self.__model__, records, 138 | ) 139 | 140 | @Api.recordset 141 | def _iter_call(self, method_name): 142 | return [ 143 | getattr(r, method_name)(self.__five9__) for r in self.__records__ 144 | ] 145 | -------------------------------------------------------------------------------- /five9/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | 6 | class Five9Exception(Exception): 7 | """Base Five9 Exceptions.""" 8 | 9 | 10 | class ValidationError(Five9Exception): 11 | """Indicated an error validating user supplied data.""" 12 | -------------------------------------------------------------------------------- /five9/five9.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import requests 6 | import zeep 7 | 8 | from collections import OrderedDict 9 | 10 | try: 11 | from urllib.parse import quote 12 | except ImportError: 13 | from urllib import quote 14 | 15 | from .environment import Environment 16 | 17 | 18 | class Five9(object): 19 | 20 | WSDL_CONFIGURATION = 'https://api.five9.com/wsadmin/v9_5/' \ 21 | 'AdminWebService?wsdl&user=%s' 22 | WSDL_SUPERVISOR = 'https://api.five9.com/wssupervisor/v9_5/' \ 23 | 'SupervisorWebService?wsdl&user=%s' 24 | 25 | # These attributes are used to create the supervisor session. 26 | force_logout_session = True 27 | rolling_period = 'Minutes30' 28 | statistics_range = 'CurrentWeek' 29 | shift_start_hour = 8 30 | time_zone_offset = -7 31 | 32 | # API Objects 33 | _api_configuration = None 34 | _api_supervisor = None 35 | _api_supervisor_session = None 36 | 37 | @property 38 | def configuration(self): 39 | """Return an authenticated connection for use, open new if required. 40 | 41 | Returns: 42 | AdminWebService: New or existing session with the Five9 Admin Web 43 | Services API. 44 | """ 45 | return self._cached_client('configuration') 46 | 47 | @property 48 | def supervisor(self): 49 | """Return an authenticated connection for use, open new if required. 50 | 51 | Returns: 52 | SupervisorWebService: New or existing session with the Five9 53 | Statistics API. 54 | """ 55 | supervisor = self._cached_client('supervisor') 56 | if not self._api_supervisor_session: 57 | self._api_supervisor_session = self.__create_supervisor_session( 58 | supervisor, 59 | ) 60 | return supervisor 61 | 62 | def __init__(self, username, password): 63 | self.username = username 64 | self.auth = requests.auth.HTTPBasicAuth(username, password) 65 | self.env = Environment(self) 66 | 67 | @staticmethod 68 | def create_mapping(record, keys): 69 | """Create a field mapping for use in API updates and creates. 70 | 71 | Args: 72 | record (BaseModel): Record that should be mapped. 73 | keys (list[str]): Fields that should be mapped as keys. 74 | 75 | Returns: 76 | dict: Dictionary with keys: 77 | 78 | * ``field_mappings``: Field mappings as required by API. 79 | * ``data``: Ordered data dictionary for input record. 80 | """ 81 | 82 | ordered = OrderedDict() 83 | field_mappings = [] 84 | 85 | for key, value in record.items(): 86 | ordered[key] = value 87 | field_mappings.append({ 88 | 'columnNumber': len(ordered), # Five9 is not zero indexed. 89 | 'fieldName': key, 90 | 'key': key in keys, 91 | }) 92 | 93 | return { 94 | 'field_mappings': field_mappings, 95 | 'data': ordered, 96 | 'fields': list(ordered.values()), 97 | } 98 | 99 | @staticmethod 100 | def parse_response(fields, records): 101 | """Parse an API response into usable objects. 102 | 103 | Args: 104 | fields (list[str]): List of strings indicating the fields that 105 | are represented in the records, in the order presented in 106 | the records.:: 107 | 108 | [ 109 | 'number1', 110 | 'number2', 111 | 'number3', 112 | 'first_name', 113 | 'last_name', 114 | 'company', 115 | 'street', 116 | 'city', 117 | 'state', 118 | 'zip', 119 | ] 120 | 121 | records (list[dict]): A really crappy data structure representing 122 | records as returned by Five9:: 123 | 124 | [ 125 | { 126 | 'values': { 127 | 'data': [ 128 | '8881234567', 129 | None, 130 | None, 131 | 'Dave', 132 | 'Lasley', 133 | 'LasLabs Inc', 134 | None, 135 | 'Las Vegas', 136 | 'NV', 137 | '89123', 138 | ] 139 | } 140 | } 141 | ] 142 | 143 | Returns: 144 | list[dict]: List of parsed records. 145 | """ 146 | data = [i['values']['data'] for i in records] 147 | return [ 148 | {fields[idx]: row for idx, row in enumerate(d)} 149 | for d in data 150 | ] 151 | 152 | @classmethod 153 | def create_criteria(cls, query): 154 | """Return a criteria from a dictionary containing a query. 155 | 156 | Query should be a dictionary, keyed by field name. If the value is 157 | a list, it will be divided into multiple criteria as required. 158 | """ 159 | criteria = [] 160 | for name, value in query.items(): 161 | if isinstance(value, list): 162 | for inner_value in value: 163 | criteria += cls.create_criteria({name: inner_value}) 164 | else: 165 | criteria.append({ 166 | 'criteria': { 167 | 'field': name, 168 | 'value': value, 169 | }, 170 | }) 171 | return criteria or None 172 | 173 | def _get_authenticated_client(self, wsdl): 174 | """Return an authenticated SOAP client. 175 | 176 | Returns: 177 | zeep.Client: Authenticated API client. 178 | """ 179 | return zeep.Client( 180 | wsdl % quote(self.username), 181 | transport=zeep.Transport( 182 | session=self._get_authenticated_session(), 183 | ), 184 | ) 185 | 186 | def _get_authenticated_session(self): 187 | """Return an authenticated requests session. 188 | 189 | Returns: 190 | requests.Session: Authenticated session for use. 191 | """ 192 | session = requests.Session() 193 | session.auth = self.auth 194 | return session 195 | 196 | def _cached_client(self, client_type): 197 | attribute = '_api_%s' % client_type 198 | if not getattr(self, attribute, None): 199 | wsdl = getattr(self, 'WSDL_%s' % client_type.upper()) 200 | client = self._get_authenticated_client(wsdl) 201 | setattr(self, attribute, client) 202 | return getattr(self, attribute).service 203 | 204 | def __create_supervisor_session(self, supervisor): 205 | """Create a new session on the supervisor service. 206 | 207 | This is required in order to use most methods for the supervisor, 208 | so it is called implicitly when generating a supervisor session. 209 | """ 210 | session_params = { 211 | 'forceLogoutSession': self.force_logout_session, 212 | 'rollingPeriod': self.rolling_period, 213 | 'statisticsRange': self.statistics_range, 214 | 'shiftStart': self.__to_milliseconds( 215 | self.shift_start_hour, 216 | ), 217 | 'timeZone': self.__to_milliseconds( 218 | self.time_zone_offset, 219 | ), 220 | } 221 | supervisor.setSessionParameters(session_params) 222 | return session_params 223 | 224 | @staticmethod 225 | def __to_milliseconds(hour): 226 | return hour * 60 * 60 * 1000 227 | -------------------------------------------------------------------------------- /five9/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | from .disposition import Disposition 6 | from .disposition_type_params import DispositionTypeParams 7 | from .key_value_pair import KeyValuePair 8 | from .timer import Timer 9 | from .web_connector import WebConnector 10 | 11 | 12 | __all__ = [ 13 | 'Disposition', 14 | 'DispositionTypeParams', 15 | 'KeyValuePair', 16 | 'Timer', 17 | 'WebConnector', 18 | ] 19 | -------------------------------------------------------------------------------- /five9/models/base_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import properties 6 | 7 | from zeep.helpers import serialize_object 8 | from six import string_types 9 | 10 | 11 | class BaseModel(properties.HasProperties): 12 | """All models should be inherited from this. 13 | 14 | Currently it does nothing other than provide a common inheritance point 15 | within this library, plus a CRUD skeleton. 16 | """ 17 | 18 | # This is the attribute on Five9 that serves as the UID for Five9. 19 | # Typically this is ``name``. 20 | __uid_field__ = 'name' 21 | 22 | @classmethod 23 | def create(cls, five9, data, refresh=False): 24 | """Create a record on Five9. 25 | 26 | Args: 27 | five9 (five9.Five9): The authenticated Five9 remote. 28 | data (dict): A data dictionary that can be fed to ``deserialize``. 29 | refresh (bool, optional): Set to ``True`` to get the record data 30 | from Five9 before returning the record. 31 | 32 | Returns: 33 | BaseModel: The newly created record. If ``refresh`` is ``True``, 34 | this will be fetched from Five9. Otherwise, it's the data 35 | record that was sent to the server. 36 | """ 37 | raise NotImplementedError() 38 | 39 | @classmethod 40 | def search(cls, five9, filters): 41 | """Search for a record on the remote and return the results. 42 | 43 | Args: 44 | five9 (five9.Five9): The authenticated Five9 remote. 45 | filters (dict): A dictionary of search parameters, keyed by the 46 | name of the field to search. This should conform to the 47 | schema defined in :func:`five9.Five9.create_criteria`. 48 | 49 | Returns: 50 | list[BaseModel]: A list of records representing the result. 51 | """ 52 | raise NotImplementedError() 53 | 54 | @classmethod 55 | def read(cls, five9, external_id): 56 | """Return a record singleton for the ID. 57 | 58 | Args: 59 | five9 (five9.Five9): The authenticated Five9 remote. 60 | external_id (mixed): The identified on Five9. This should be the 61 | value that is in the ``__uid_field__`` field on the record. 62 | 63 | Returns: 64 | BaseModel: The record, if found. Otherwise ``None`` 65 | """ 66 | results = cls.search(five9, {cls.__uid_field__: external_id}) 67 | if not results: 68 | return None 69 | return results[0] 70 | 71 | def delete(self, five9): 72 | """Delete the record from the remote. 73 | 74 | Args: 75 | five9 (five9.Five9): The authenticated Five9 remote. 76 | """ 77 | raise NotImplementedError() 78 | 79 | def get(self, key, default=None): 80 | """Return the field indicated by the key, if present.""" 81 | try: 82 | return self.__getitem__(key) 83 | except KeyError: 84 | return default 85 | 86 | def update(self, data): 87 | """Update the current memory record with the given data dict. 88 | 89 | Args: 90 | data (dict): Data dictionary to update the record attributes with. 91 | """ 92 | for key, value in data.items(): 93 | setattr(self, key, value) 94 | 95 | def write(self, five9): 96 | """Write the record to the remote. 97 | 98 | Args: 99 | five9 (five9.Five9): The authenticated Five9 remote. 100 | """ 101 | raise NotImplementedError() 102 | 103 | @classmethod 104 | def _call_and_serialize(cls, method, data, refresh=False): 105 | """Call the remote method with data, and optionally refresh. 106 | 107 | Args: 108 | method (callable): The method on the Authenticated Five9 object 109 | that should be called. 110 | data (dict): A data dictionary that will be passed as the first 111 | and only position argument to ``method``. 112 | refresh (bool, optional): Set to ``True`` to get the record data 113 | from Five9 before returning the record. 114 | 115 | Returns: 116 | BaseModel: The newly created record. If ``refresh`` is ``True``, 117 | this will be fetched from Five9. Otherwise, it's the data 118 | record that was sent to the server. 119 | """ 120 | method(data) 121 | if refresh: 122 | return cls.read(method.__self__, data[cls.__uid_field__]) 123 | else: 124 | return cls.deserialize(cls._get_non_empty_dict(data)) 125 | 126 | @classmethod 127 | def _get_name_filters(cls, filters): 128 | """Return a regex filter for the UID column only.""" 129 | filters = filters.get(cls.__uid_field__) 130 | if not filters: 131 | filters = '.*' 132 | elif not isinstance(filters, string_types): 133 | filters = r'(%s)' % ('|'.join(filters)) 134 | return filters 135 | 136 | @classmethod 137 | def _get_non_empty_dict(cls, mapping): 138 | """Return the mapping without any ``None`` values (recursive).""" 139 | res = {} 140 | for key, value in mapping.items(): 141 | if hasattr(value, 'items'): 142 | value = cls._get_non_empty_dict(value) 143 | elif isinstance(value, list): 144 | value = cls._get_non_empty_list(value) 145 | if value not in [[], {}, None]: 146 | res[key] = value 147 | return res 148 | 149 | @classmethod 150 | def _get_non_empty_list(cls, iter): 151 | """Return a list of the input, excluding all ``None`` values.""" 152 | res = [] 153 | for value in iter: 154 | if hasattr(value, 'items'): 155 | value = cls._get_non_empty_dict(value) or None 156 | if value is not None: 157 | res.append(value) 158 | return res 159 | 160 | @classmethod 161 | def _name_search(cls, method, filters): 162 | """Helper for search methods that use name filters. 163 | 164 | Args: 165 | method (callable): The Five9 API method to call with the name 166 | filters. 167 | filters (dict): A dictionary of search parameters, keyed by the 168 | name of the field to search. This should conform to the 169 | schema defined in :func:`five9.Five9.create_criteria`. 170 | 171 | Returns: 172 | list[BaseModel]: A list of records representing the result. 173 | """ 174 | filters = cls._get_name_filters(filters) 175 | return [ 176 | cls.deserialize(cls._zeep_to_dict(row)) for row in method(filters) 177 | ] 178 | 179 | @classmethod 180 | def _zeep_to_dict(cls, obj): 181 | """Convert a zeep object to a dictionary.""" 182 | res = serialize_object(obj) 183 | res = cls._get_non_empty_dict(res) 184 | return res 185 | 186 | def __getitem__(self, item): 187 | """Return the field indicated by the key, if present. 188 | This is better than using ``getattr`` because it will not expose any 189 | properties that are not meant to be fields for the object. 190 | Raises: 191 | KeyError: In the event that the field doesn't exist. 192 | """ 193 | self.__check_field(item) 194 | return getattr(self, item) 195 | 196 | def __setitem__(self, key, value): 197 | """Return the field indicated by the key, if present. 198 | This is better than using ``getattr`` because it will not expose any 199 | properties that are not meant to be fields for the object. 200 | Raises: 201 | KeyError: In the event that the field doesn't exist. 202 | """ 203 | self.__check_field(key) 204 | return setattr(self, key, value) 205 | 206 | def __check_field(self, key): 207 | """Raises a KeyError if the field doesn't exist.""" 208 | if not self._props.get(key): 209 | raise KeyError( 210 | 'The field "%s" does not exist on "%s"' % ( 211 | key, self.__class__.__name__, 212 | ), 213 | ) 214 | -------------------------------------------------------------------------------- /five9/models/disposition.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import properties 6 | 7 | from .base_model import BaseModel 8 | from .disposition_type_params import DispositionTypeParams 9 | 10 | 11 | class Disposition(BaseModel): 12 | 13 | agentMustCompleteWorksheet = properties.Bool( 14 | 'Whether the agent needs to complete a worksheet before selecting ' 15 | 'a disposition.', 16 | ) 17 | agentMustConfirm = properties.Bool( 18 | 'Whether the agent is prompted to confirm the selection of the ' 19 | 'disposition.', 20 | ) 21 | description = properties.String( 22 | 'Description of the disposition.', 23 | ) 24 | name = properties.String( 25 | 'Name of the disposition.', 26 | required=True, 27 | ) 28 | resetAttemptsCounter = properties.Bool( 29 | 'Whether assigning the disposition resets the number of dialing ' 30 | 'attempts for this contact.', 31 | ) 32 | sendEmailNotification = properties.Bool( 33 | 'Whether call details are sent as an email notification when the ' 34 | 'disposition is used by an agent.', 35 | ) 36 | sendIMNotification = properties.Bool( 37 | 'Whether call details are sent as an instant message in the Five9 ' 38 | 'system when the disposition is used by an agent.', 39 | ) 40 | trackAsFirstCallResolution = properties.Bool( 41 | 'Whether the call is included in the first call resolution ' 42 | 'statistics (customer\'s needs addressed in the first call). Used ' 43 | 'primarily for inbound campaigns.', 44 | ) 45 | type = properties.StringChoice( 46 | 'Disposition type.', 47 | choices=['FinalDisp', 48 | 'FinalApplyToCampaigns', 49 | 'AddActiveNumber', 50 | 'AddAndFinalize', 51 | 'AddAllNumbers', 52 | 'DoNotDial', 53 | 'RedialNumber', 54 | ], 55 | descriptions={ 56 | 'FinalDisp': 57 | 'Any contact number of the contact is not dialed again by ' 58 | 'the current campaign.', 59 | 'FinalApplyToCampaigns': 60 | 'Contact is not dialed again by any campaign that contains ' 61 | 'the disposition.', 62 | 'AddActiveNumber': 63 | 'Adds the number dialed to the DNC list.', 64 | 'AddAndFinalize': 65 | 'Adds the call results to the campaign history. This record ' 66 | 'is no longer dialing in this campaign. Does not add the ' 67 | 'contact\'s other phone numbers to the DNC list.', 68 | 'AddAllNumbers': 69 | 'Adds all the contact\'s phone numbers to the DNC list.', 70 | 'DoNotDial': 71 | 'Number is not dialed in the campaign, but other numbers ' 72 | 'from the CRM record can be dialed.', 73 | 'RedialNumber': 74 | 'Number is dialed again when the list to dial is completed, ' 75 | 'and the dialer starts again from the beginning.', 76 | }, 77 | ) 78 | typeParameters = properties.Instance( 79 | 'Parameters that apply to the disposition type.', 80 | instance_class=DispositionTypeParams, 81 | ) 82 | 83 | @classmethod 84 | def create(cls, five9, data, refresh=False): 85 | """Create a record on Five9. 86 | 87 | Args: 88 | five9 (five9.Five9): The authenticated Five9 remote. 89 | data (dict): A data dictionary that can be fed to ``deserialize``. 90 | refresh (bool, optional): Set to ``True`` to get the record data 91 | from Five9 before returning the record. 92 | 93 | Returns: 94 | BaseModel: The newly created record. If ``refresh`` is ``True``, 95 | this will be fetched from Five9. Otherwise, it's the data 96 | record that was sent to the server. 97 | """ 98 | return cls._call_and_serialize( 99 | five9.configuration.createDisposition, data, refresh, 100 | ) 101 | 102 | @classmethod 103 | def search(cls, five9, filters): 104 | """Search for a record on the remote and return the results. 105 | 106 | Args: 107 | five9 (five9.Five9): The authenticated Five9 remote. 108 | filters (dict): A dictionary of search parameters, keyed by the 109 | name of the field to search. This should conform to the 110 | schema defined in :func:`five9.Five9.create_criteria`. 111 | 112 | Returns: 113 | list[BaseModel]: A list of records representing the result. 114 | """ 115 | return cls._name_search(five9.configuration.getDispositions, filters) 116 | 117 | def delete(self, five9): 118 | """Delete the record from the remote. 119 | 120 | Args: 121 | five9 (five9.Five9): The authenticated Five9 remote. 122 | """ 123 | five9.configuration.removeDisposition(self.name) 124 | 125 | def write(self, five9): 126 | """Update the record on the remote. 127 | 128 | Args: 129 | five9 (five9.Five9): The authenticated Five9 remote. 130 | """ 131 | five9.configuration.modifyDisposition(self.serialize()) 132 | -------------------------------------------------------------------------------- /five9/models/disposition_type_params.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import properties 6 | 7 | from .base_model import BaseModel 8 | from .timer import Timer 9 | 10 | 11 | class DispositionTypeParams(BaseModel): 12 | 13 | allowChangeTimer = properties.Bool( 14 | 'Whether the agent can change the redial timer for this disposition.', 15 | ) 16 | attempts = properties.Integer( 17 | 'Number of redial attempts.', 18 | ) 19 | timer = properties.Instance( 20 | 'Redial timer.', 21 | instance_class=Timer, 22 | ) 23 | useTimer = properties.Bool( 24 | 'Whether this disposition uses a redial timer.', 25 | ) 26 | -------------------------------------------------------------------------------- /five9/models/key_value_pair.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import properties 6 | 7 | from .base_model import BaseModel 8 | 9 | 10 | class KeyValuePair(BaseModel): 11 | 12 | key = properties.String( 13 | 'Name used to identify the pair.', 14 | required=True, 15 | ) 16 | value = properties.String( 17 | 'Value that corresponds to the name.', 18 | required=True, 19 | ) 20 | 21 | def __init__(self, key, value, **kwargs): 22 | """Allow for positional key, val pairs.""" 23 | super(KeyValuePair, self).__init__( 24 | key=key, value=value, **kwargs 25 | ) 26 | -------------------------------------------------------------------------------- /five9/models/timer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import properties 6 | 7 | from .base_model import BaseModel 8 | 9 | 10 | class Timer(BaseModel): 11 | 12 | days = properties.Integer( 13 | 'Number of days.' 14 | ) 15 | hours = properties.Integer( 16 | 'Number of hours.', 17 | ) 18 | minutes = properties.Integer( 19 | 'Number of minutes.', 20 | ) 21 | seconds = properties.Integer( 22 | 'Number of seconds.', 23 | ) 24 | -------------------------------------------------------------------------------- /five9/models/web_connector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import properties 6 | 7 | from .base_model import BaseModel 8 | from .disposition import Disposition 9 | from .key_value_pair import KeyValuePair 10 | 11 | 12 | class WebConnector(BaseModel): 13 | """Contains the configuration details of a web connector.""" 14 | 15 | addWorksheet = properties.Bool( 16 | 'Applies only to POST requests. Whether to pass worksheet ' 17 | 'answers as parameters.', 18 | ) 19 | agentApplication = properties.StringChoice( 20 | 'If ``executeInBrowser==True``, this parameter specifies whether ' 21 | 'to open the URL in an external or an embedded browser.', 22 | default='EmbeddedBrowser', 23 | required=True, 24 | choices=['EmbeddedBrowser', 'ExternalBrowser'], 25 | descriptions={ 26 | 'EmbeddedBrowser': 'Embedded browser window.', 27 | 'ExternalBrowser': 'External browser window.', 28 | }, 29 | ) 30 | clearTriggerDispositions = properties.Bool( 31 | 'When modifying an existing connector, whether to clear the existing ' 32 | 'triggers.' 33 | ) 34 | constants = properties.List( 35 | 'List of parameters passed with constant values.', 36 | prop=KeyValuePair, 37 | ) 38 | ctiWebServices = properties.StringChoice( 39 | 'In the Internet Explorer toolbar, whether to open the HTTP request ' 40 | 'in the current or a new browser window.', 41 | default='CurrentBrowserWindow', 42 | required=True, 43 | choices=['CurrentBrowserWindow', 'NewBrowserWindow'], 44 | descriptions={ 45 | 'CurrentBrowserWindow': 'Current browser window.', 46 | 'NewBrowserWindow': 'New browser window.', 47 | }, 48 | ) 49 | description = properties.String( 50 | 'Purpose of the connector.', 51 | required=True, 52 | ) 53 | executeInBrowser = properties.Bool( 54 | 'When enabling the agent to view or enter data, whether to open ' 55 | 'the URL in an embedded or external browser window.', 56 | required=True, 57 | ) 58 | name = properties.String( 59 | 'Name of the connector', 60 | required=True, 61 | ) 62 | postConstants = properties.List( 63 | 'When using the POST method, constant parameters to pass in the URL.', 64 | prop=KeyValuePair, 65 | ) 66 | postMethod = properties.Bool( 67 | 'Whether the HTTP request type is POST.', 68 | ) 69 | postVariables = properties.List( 70 | 'When using the POST method, variable parameters to pass in the URL.', 71 | prop=KeyValuePair, 72 | ) 73 | startPageText = properties.String( 74 | 'When using the POST method, enables the administrator to enter text ' 75 | 'to be displayed in the browser (or agent Browser tab) while waiting ' 76 | 'for the completion of the connector.', 77 | ) 78 | trigger = properties.StringChoice( 79 | 'Available trigger during a call when the request is sent.', 80 | required=True, 81 | choices=['OnCallAccepted', 82 | 'OnCallDisconnected', 83 | 'ManuallyStarted', 84 | 'ManuallyStartedAllowDuringPreviews', 85 | 'OnPreview', 86 | 'OnContactSelection', 87 | 'OnWarmTransferInitiation', 88 | 'OnCallDispositioned', 89 | 'OnChatArrival', 90 | 'OnChatTransfer', 91 | 'OnChatTermination', 92 | 'OnChatClose', 93 | 'OnEmailArrival', 94 | 'OnEmailTransfer', 95 | 'OnEmailClose', 96 | ], 97 | descriptions={ 98 | 'OnCallAccepted': 'Triggered when the call is accepted.', 99 | 'OnCallDisconnected': 'Triggered when the call is disconnected.', 100 | 'ManuallyStarted': 'Connector is started manually.', 101 | 'ManuallyStartedAllowDuringPreviews': 'Connector is started ' 102 | 'manually during call ' 103 | 'preview.', 104 | 'OnPreview': 'Triggered when the call is previewed.', 105 | 'OnContactSelection': 'Triggered when a contact is selected.', 106 | 'OnWarmTransferInitiation': 'Triggered when a warm transfer is ' 107 | 'initiated.', 108 | 'OnCallDispositioned': 'Triggered when a disposition is ' 109 | 'selected.', 110 | 'OnChatArrival': 'Triggered when a chat message is delivered to ' 111 | 'an agent.', 112 | 'OnChatTransfer': 'Triggered when a chat session is transferred.', 113 | 'OnChatTermination': 'Triggered when the customer or the agent ' 114 | 'closed the session, but the agent has not ' 115 | 'yet set the disposition.', 116 | 'OnChatClose': 'Triggered when the disposition is set.', 117 | 'OnEmailArrival': 'Triggered when an email message is delivered ' 118 | 'to the agent.', 119 | 'OnEmailTransfer': 'Triggered when an email message is ' 120 | 'transferred.', 121 | 'OnEmailClose': 'Triggered when the disposition is set.', 122 | } 123 | ) 124 | triggerDispositions = properties.List( 125 | 'When the trigger is OnCallDispositioned, specifies the trigger ' 126 | 'dispositions.', 127 | prop=Disposition, 128 | ) 129 | url = properties.String( 130 | 'URI of the external web site.', 131 | ) 132 | variables = properties.List( 133 | 'When using the POST method, connectors can include worksheet data ' 134 | 'as parameter values. The variable placeholder values are surrounded ' 135 | 'by @ signs. For example, the parameter ANI has the value @Call.ANI@', 136 | prop=KeyValuePair, 137 | ) 138 | 139 | @classmethod 140 | def create(cls, five9, data, refresh=False): 141 | return cls._call_and_serialize( 142 | five9.configuration.createWebConnector, data, refresh, 143 | ) 144 | 145 | @classmethod 146 | def search(cls, five9, filters): 147 | """Search for a record on the remote and return the results. 148 | 149 | Args: 150 | five9 (five9.Five9): The authenticated Five9 remote. 151 | filters (dict): A dictionary of search parameters, keyed by the 152 | name of the field to search. This should conform to the 153 | schema defined in :func:`five9.Five9.create_criteria`. 154 | 155 | Returns: 156 | list[BaseModel]: A list of records representing the result. 157 | """ 158 | return cls._name_search(five9.configuration.getWebConnectors, filters) 159 | 160 | def delete(self, five9): 161 | """Delete the record from the remote. 162 | 163 | Args: 164 | five9 (five9.Five9): The authenticated Five9 remote. 165 | """ 166 | five9.configuration.deleteWebConnector(self.name) 167 | 168 | def write(self, five9): 169 | """Update the record on the remote. 170 | 171 | Args: 172 | five9 (five9.Five9): The authenticated Five9 remote. 173 | """ 174 | five9.configuration.modifyWebConnector(self.serialize()) 175 | -------------------------------------------------------------------------------- /five9/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | -------------------------------------------------------------------------------- /five9/tests/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import unittest 6 | 7 | from ..five9 import Five9 8 | 9 | 10 | class Common(unittest.TestCase): 11 | 12 | def setUp(self): 13 | super(Common, self).setUp() 14 | self.user = 'username@something.com' 15 | self.password = 'password' 16 | self.five9 = Five9(self.user, self.password) 17 | -------------------------------------------------------------------------------- /five9/tests/common_crud.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import mock 6 | 7 | from ..models.base_model import BaseModel 8 | 9 | 10 | class CommonCrud(object): 11 | 12 | Model = BaseModel 13 | five9_api = 'configuration' 14 | 15 | def setUp(self): 16 | super(CommonCrud, self).setUp() 17 | self.data = { 18 | 'description': 'Test', 19 | self.Model.__uid_field__: 'Test', 20 | } 21 | self.five9 = mock.MagicMock() 22 | self.model_name = self.Model.__name__ 23 | self.method_names = { 24 | 'create': 'create%(model_name)s', 25 | 'write': 'modify%(model_name)s', 26 | 'search': 'get%(model_name)ss', 27 | 'delete': 'delete%(model_name)s', 28 | } 29 | 30 | def _get_method(self, method_type): 31 | method_name = self.method_names[method_type] % { 32 | 'model_name': self.model_name, 33 | } 34 | api = getattr(self.five9, self.five9_api) 35 | return getattr(api, method_name) 36 | 37 | def test_create(self): 38 | """It should use the proper method and args on the API with.""" 39 | self.Model.create(self.five9, self.data) 40 | self._get_method('create').assert_called_once_with(self.data) 41 | 42 | def test_search(self): 43 | """It should search the remote for the name.""" 44 | self.Model.search(self.five9, self.data) 45 | self._get_method('search').assert_called_once_with( 46 | self.data[self.Model.__uid_field__], 47 | ) 48 | 49 | def test_search_multiple(self): 50 | """It should search the remote for the conjoined names.""" 51 | self.data['name'] = ['Test1', 'Test2'] 52 | self.Model.search(self.five9, self.data) 53 | self._get_method('search').assert_called_once_with( 54 | r'(Test1|Test2)', 55 | ) 56 | 57 | def test_search_return(self): 58 | """It should return a list of the result objects.""" 59 | self._get_method('search').return_value = [ 60 | self.data, self.data, 61 | ] 62 | results = self.Model.search(self.five9, self.data) 63 | self.assertEqual(len(results), 2) 64 | expect = self.Model(**self.data).serialize() 65 | for result in results: 66 | self.assertIsInstance(result, self.Model) 67 | self.assertDictEqual(result.serialize(), expect) 68 | 69 | def test_delete(self): 70 | """It should call the delete method and args on the API.""" 71 | self.Model(**self.data).delete(self.five9) 72 | self._get_method('delete').assert_called_once_with( 73 | self.data[self.Model.__uid_field__], 74 | ) 75 | 76 | def test_write(self): 77 | """It should call the write method on the API.""" 78 | self.Model(**self.data).write(self.five9) 79 | self._get_method('write').assert_called_once_with( 80 | self.Model(**self.data).serialize(), 81 | ) 82 | -------------------------------------------------------------------------------- /five9/tests/test_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import unittest 6 | 7 | from ..environment import Api 8 | from ..exceptions import ValidationError 9 | 10 | 11 | class TestRecord(object): 12 | 13 | __model__ = None 14 | __records__ = None 15 | 16 | @Api.model 17 | def model(self): 18 | return True 19 | 20 | @Api.recordset 21 | def recordset(self): 22 | return True 23 | 24 | 25 | class TestApi(unittest.TestCase): 26 | 27 | def setUp(self): 28 | super(TestApi, self).setUp() 29 | self.record = TestRecord() 30 | 31 | def test_model_bad(self): 32 | """It should raise ValidationError when no model.""" 33 | with self.assertRaises(ValidationError): 34 | self.record.model() 35 | 36 | def test_recordset_bad(self): 37 | """It should raise ValidationError when no recordset.""" 38 | self.record.__model__ = False 39 | with self.assertRaises(ValidationError): 40 | self.record.recordset() 41 | 42 | def test_recordset_model(self): 43 | """It should raise ValidationError when recordset but no model.""" 44 | with self.assertRaises(ValidationError): 45 | self.record.__records__ = [1] 46 | self.record.recordset() 47 | 48 | def test_recordset_valid(self): 49 | """It should return True when valid recordset method.""" 50 | self.record.__records__ = [1] 51 | self.record.__model__ = True 52 | self.assertTrue(self.record.recordset()) 53 | 54 | def test_model_valid(self): 55 | """It should return True when valid model method.""" 56 | self.record.__model__ = True 57 | self.assertTrue(self.record.model()) 58 | -------------------------------------------------------------------------------- /five9/tests/test_base_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import mock 6 | import properties 7 | import unittest 8 | 9 | from ..models.base_model import BaseModel 10 | 11 | 12 | class TestModel(BaseModel): 13 | id = properties.Integer('ID') 14 | not_a_field = True 15 | 16 | 17 | class TestBaseModel(unittest.TestCase): 18 | 19 | def setUp(self): 20 | super(TestBaseModel, self).setUp() 21 | self.called_with = None 22 | self.id = 1234 23 | 24 | def new_record(self): 25 | return TestModel( 26 | id=self.id, 27 | ) 28 | 29 | def _test_method(self, data): 30 | self.called_with = data 31 | 32 | def test_read_none(self): 33 | """It should return None if no result.""" 34 | with mock.patch.object(BaseModel, 'search') as search: 35 | search.return_value = [] 36 | self.assertIsNone(BaseModel.read(None, None)) 37 | 38 | def _call_and_serialize(self, data=None, refresh=False): 39 | method = self._test_method 40 | result = BaseModel._call_and_serialize(method, data, refresh) 41 | return result 42 | 43 | def test_read_results(self): 44 | """It should return the first result.""" 45 | with mock.patch.object(BaseModel, 'search') as search: 46 | search.return_value = [1, 2] 47 | self.assertEqual(BaseModel.read(None, None), 1) 48 | 49 | def test_read_search(self): 50 | """It should perform the proper search.""" 51 | with mock.patch.object(BaseModel, 'search') as search: 52 | BaseModel.read('five9', 'external_id') 53 | search.assert_called_once_with('five9', {'name': 'external_id'}) 54 | 55 | def test_call_and_serialize_refresh_return(self): 56 | """It should return the refreshed object.""" 57 | data = {'name': 'test'} 58 | with mock.patch.object(BaseModel, 'read') as read: 59 | result = self._call_and_serialize(data, True) 60 | read.assert_called_once_with(self, data['name']) 61 | self.assertEqual(self.called_with, data) 62 | self.assertEqual(result, read()) 63 | 64 | def test_call_and_serialize_no_refresh(self): 65 | """It should return the deserialized data.""" 66 | data = {'name': 'test'} 67 | with mock.patch.object(BaseModel, 'deserialize') as deserialize: 68 | result = self._call_and_serialize(data, False) 69 | deserialize.assert_called_once_with(data) 70 | self.assertEqual(self.called_with, data) 71 | self.assertEqual(result, deserialize()) 72 | 73 | def test_update(self): 74 | """It should set the attributes to the provided values.""" 75 | data = {'test1': 12345, 'test2': 54321} 76 | record = self.new_record() 77 | record.update(data) 78 | for key, value in data.items(): 79 | self.assertEqual(getattr(record, key), value) 80 | 81 | def test__get_non_empty_dict(self): 82 | """It should return the dict without NoneTypes.""" 83 | expect = { 84 | 'good_int': 1234, 85 | 'good_false': False, 86 | 'good_true': True, 87 | 'bad': None, 88 | 'bad_dict': {'key': None}, 89 | 'bad_list': [None], 90 | 'bad_list_with_dict': [{'key': None}], 91 | 'good_list': [1, 2], 92 | 'good_list_with_dict': [{'key': 1}], 93 | } 94 | res = BaseModel._get_non_empty_dict(expect) 95 | del expect['bad'], \ 96 | expect['bad_dict'], \ 97 | expect['bad_list'], \ 98 | expect['bad_list_with_dict'] 99 | self.assertDictEqual(res, expect) 100 | 101 | def test_dict_lookup_exist(self): 102 | """It should return the attribute value when it exists.""" 103 | self.assertEqual( 104 | self.new_record()['id'], self.id, 105 | ) 106 | 107 | def test_dict_lookup_no_exist(self): 108 | """It should raise a KeyError when the attribute isn't a field.""" 109 | with self.assertRaises(KeyError): 110 | self.new_record()['not_a_field'] 111 | 112 | def test_dict_set_exist(self): 113 | """It should set the attribute via the items.""" 114 | expect = 4321 115 | record = self.new_record() 116 | record['id'] = expect 117 | self.assertEqual(record.id, expect) 118 | 119 | def test_dict_set_no_exist(self): 120 | """It should raise a KeyError and not change the non-field.""" 121 | record = self.new_record() 122 | with self.assertRaises(KeyError): 123 | record['not_a_field'] = False 124 | self.assertTrue(record.not_a_field) 125 | 126 | def test_get_exist(self): 127 | """It should return the attribute if it exists.""" 128 | self.assertEqual( 129 | self.new_record().get('id'), self.id, 130 | ) 131 | 132 | def test_get_no_exist(self): 133 | """It should return the default if the attribute doesn't exist.""" 134 | expect = 'Test' 135 | self.assertEqual( 136 | self.new_record().get('not_a_field', expect), expect, 137 | ) 138 | -------------------------------------------------------------------------------- /five9/tests/test_disposition.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import unittest 6 | 7 | from ..models.disposition import Disposition 8 | 9 | from .common_crud import CommonCrud 10 | 11 | 12 | class TestDisposition(CommonCrud, unittest.TestCase): 13 | 14 | Model = Disposition 15 | 16 | def setUp(self): 17 | super(TestDisposition, self).setUp() 18 | self.method_names['delete'] = 'remove%(model_name)s' 19 | -------------------------------------------------------------------------------- /five9/tests/test_environment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import mock 6 | import unittest 7 | 8 | from ..environment import Environment 9 | from ..five9 import Five9 10 | from ..models.web_connector import WebConnector 11 | 12 | 13 | class TestEnvironment(unittest.TestCase): 14 | 15 | def setUp(self): 16 | super(TestEnvironment, self).setUp() 17 | self.five9 = mock.MagicMock(spec=Five9) 18 | self.records = [ 19 | mock.MagicMock(spec=WebConnector), 20 | mock.MagicMock(spec=WebConnector), 21 | ] 22 | self.model = mock.MagicMock(spec=WebConnector) 23 | self.model.__name__ = 'name' 24 | self.env = Environment(self.five9, self.model, self.records) 25 | 26 | def _test_iter_method(self, method_name): 27 | getattr(self.env, method_name)() 28 | for record in self.records: 29 | getattr(record, method_name).assert_called_once_with(self.five9) 30 | 31 | def test_new_gets_models(self): 32 | """It should assign the ``__models__`` class attribute.""" 33 | self.assertIsInstance(Environment.__models__, dict) 34 | self.assertGreater(len(Environment.__models__), 1) 35 | 36 | def test_init_sets_five9(self): 37 | """It should set the __five9__ attribute.""" 38 | self.assertEqual(self.env.__five9__, self.five9) 39 | 40 | def test_init_sets_records(self): 41 | """It should set the __records__ attribute.""" 42 | self.assertEqual(self.env.__records__, self.records) 43 | 44 | def test_init_sets_model(self): 45 | """It should set the __model__ attribute.""" 46 | self.assertEqual(self.env.__model__, self.model) 47 | 48 | def test_getattr_pass_through_to_model(self): 49 | """It should return the correct model environment.""" 50 | self.assertEqual(self.env.WebConnector.__model__, WebConnector) 51 | 52 | def test_iter(self): 53 | """It should iterate the records in the set.""" 54 | for idx, record in enumerate(self.env): 55 | self.assertEqual(record, self.records[idx]) 56 | self.assertEqual(self.env.__record__, self.records[idx]) 57 | 58 | def test_create_creates(self): 59 | """It should create the record on the remote.""" 60 | expect = {'test': 1234} 61 | self.env.create(expect) 62 | self.model.create.assert_called_once_with(self.five9, expect) 63 | 64 | def test_create_return_refreshed(self): 65 | """It should create the refreshed record when True.""" 66 | expect = {'name': 1234} 67 | with mock.patch.object(self.env, 'read') as read: 68 | res = self.env.create(expect, True) 69 | read.assert_called_once_with(expect[self.model.__name__]) 70 | self.assertEqual(res, read()) 71 | 72 | def test_create_return_deserialized(self): 73 | """It should return a deserialized memory record if no refresh.""" 74 | expect = {'test': 1234} 75 | res = self.env.create(expect, False) 76 | self.model._get_non_empty_dict.assert_called_once_with(expect) 77 | self.model.deserialize.assert_called_once_with( 78 | self.model._get_non_empty_dict(), 79 | ) 80 | self.assertEqual(len(res.__records__), 1) 81 | self.assertEqual(res.__records__[0], self.model.deserialize()) 82 | 83 | def test_read(self): 84 | """It should call and return properly.""" 85 | expect = 1234 86 | res = self.env.read(expect) 87 | self.model.read.assert_called_once_with(self.five9, expect) 88 | self.assertEqual(res, self.model.read()) 89 | 90 | def test_write(self): 91 | """It should iterate and write the recordset.""" 92 | self._test_iter_method('write') 93 | 94 | def test_delete(self): 95 | """It should iterate and delete the recordset.""" 96 | self._test_iter_method('delete') 97 | 98 | def test_search(self): 99 | """It should call search on the model and return a recordset.""" 100 | expect = {'test': 1234} 101 | results = self.env.search(expect) 102 | self.model.search.assert_called_once_with(self.five9, expect) 103 | self.assertEqual(results.__records__, self.model.search()) 104 | -------------------------------------------------------------------------------- /five9/tests/test_five9.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import mock 6 | import requests 7 | 8 | from collections import OrderedDict 9 | 10 | from .common import Common 11 | 12 | 13 | class TestFive9(Common): 14 | 15 | def test_create_criteria_flat(self): 16 | """It should return the proper criteria for the flat inputs.""" 17 | data = { 18 | 'first_name': 'Test', 19 | 'last_name': 'User', 20 | 'number1': 1234567890, 21 | } 22 | result = self.five9.create_criteria(data) 23 | self.assertEqual(len(result), len(data)) 24 | for key, value in data.items(): 25 | criteria = {'criteria': {'field': key, 'value': value}} 26 | self.assertIn(criteria, result) 27 | 28 | def test_create_criteria_list(self): 29 | """It should create multiple criteria for a list.""" 30 | data = { 31 | 'first_name': ['Test1', 'Test2'], 32 | } 33 | result = self.five9.create_criteria({ 34 | 'first_name': ['Test1', 'Test2'], 35 | }) 36 | self.assertEqual(len(result), 2) 37 | for name in data['first_name']: 38 | criteria = {'criteria': {'field': 'first_name', 'value': name}} 39 | self.assertIn(criteria, result) 40 | 41 | def test_create_mapping(self): 42 | """It should output the proper mapping.""" 43 | record = OrderedDict([ 44 | ('first_name', 'Test'), 45 | ('last_name', 'User'), 46 | ]) 47 | result = self.five9.create_mapping(record, ['last_name']) 48 | expect = { 49 | 'field_mappings': [ 50 | {'columnNumber': 1, 'fieldName': 'first_name', 'key': False}, 51 | {'columnNumber': 2, 'fieldName': 'last_name', 'key': True}, 52 | ], 53 | 'data': record, 54 | 'fields': ['Test', 'User'], 55 | } 56 | self.assertDictEqual(result, expect) 57 | 58 | def test_parse_response(self): 59 | """It should return the proper record.""" 60 | expect = [ 61 | OrderedDict([('first_name', 'Test'), ('last_name', 'User')]), 62 | OrderedDict([('first_name', 'First'), ('last_name', 'Last')]), 63 | ] 64 | fields = ['first_name', 'last_name'] 65 | records = [{'values': {'data': list(e.values())}} for e in expect] 66 | response = self.five9.parse_response(fields, records) 67 | for idx, row in enumerate(response): 68 | self.assertDictEqual(row, expect[idx]) 69 | 70 | def _test_cached_client(self, client_type): 71 | with mock.patch.object(self.five9, '_get_authenticated_client') as mk: 72 | response = getattr(self.five9, client_type) 73 | return response, mk 74 | 75 | def test_init_username(self): 76 | """It should assign the username during init.""" 77 | self.assertEqual(self.five9.username, self.user) 78 | 79 | def test_init_authentication(self): 80 | """It should create a BasicAuth object with proper args.""" 81 | self.assertIsInstance(self.five9.auth, requests.auth.HTTPBasicAuth) 82 | self.assertEqual(self.five9.auth.username, self.user) 83 | self.assertEqual(self.five9.auth.password, self.password) 84 | 85 | @mock.patch('five9.five9.zeep') 86 | def test_get_authenticated_client(self, zeep): 87 | """It should return a zeep client.""" 88 | wsdl = 'wsdl%s' 89 | response = self.five9._get_authenticated_client(wsdl) 90 | zeep.Client.assert_called_once_with( 91 | wsdl % self.user.replace('@', '%40'), 92 | transport=zeep.Transport(), 93 | ) 94 | self.assertEqual(response, zeep.Client()) 95 | 96 | def test_get_authenticated_session(self): 97 | """It should return a requests session with authentication.""" 98 | response = self.five9._get_authenticated_session() 99 | self.assertIsInstance(response, requests.Session) 100 | self.assertEqual(response.auth, self.five9.auth) 101 | 102 | def test_configuration(self): 103 | """It should return an authenticated configuration service.""" 104 | response, mk = self._test_cached_client('configuration') 105 | mk.assert_called_once_with(self.five9.WSDL_CONFIGURATION) 106 | self.assertEqual(response, mk().service) 107 | 108 | def test_supervisor(self): 109 | """It should return an authenticated supervisor service.""" 110 | response, mk = self._test_cached_client('supervisor') 111 | mk.assert_called_once_with(self.five9.WSDL_SUPERVISOR) 112 | self.assertEqual(response, mk().service) 113 | 114 | def test_supervisor_session(self): 115 | """It should automatically create a supervisor session.""" 116 | response, _ = self._test_cached_client('supervisor') 117 | response.setSessionParameters.assert_called_once_with( 118 | self.five9._api_supervisor_session, 119 | ) 120 | 121 | def test_supervisor_session_cached(self): 122 | """It should use a cached supervisor session after initial.""" 123 | response, _ = self._test_cached_client('supervisor') 124 | self._test_cached_client('supervisor') 125 | response.setSessionParameters.assert_called_once() 126 | -------------------------------------------------------------------------------- /five9/tests/test_key_value_pair.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import unittest 6 | 7 | from ..models.key_value_pair import KeyValuePair 8 | 9 | 10 | class TestKeyValuePair(unittest.TestCase): 11 | 12 | def test_init_positional(self): 13 | """It should allow positional key, value pairs.""" 14 | res = KeyValuePair('key', 'value') 15 | self.assertEqual(res.key, 'key') 16 | self.assertEqual(res.value, 'value') 17 | -------------------------------------------------------------------------------- /five9/tests/test_web_connector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2017-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | import unittest 6 | 7 | from ..models.web_connector import WebConnector 8 | 9 | from .common_crud import CommonCrud 10 | 11 | 12 | class TestWebConnector(CommonCrud, unittest.TestCase): 13 | 14 | Model = WebConnector 15 | 16 | def setUp(self): 17 | super(TestWebConnector, self).setUp() 18 | self.data['trigger'] = 'OnCallAccepted' 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | properties 2 | requests 3 | six 4 | websocket-client 5 | zeep 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2016-TODAY LasLabs Inc. 3 | # License MIT (https://opensource.org/licenses/MIT). 4 | 5 | from setuptools import Command, setup 6 | from setuptools import find_packages 7 | from unittest import TestLoader, TextTestRunner 8 | 9 | from os import environ, path 10 | 11 | 12 | PROJECT = 'five9' 13 | SHORT_DESC = 'This library allows for you to integrate with Five9 Cloud ' \ 14 | 'Contact Center using Python.' 15 | README_FILE = 'README.rst' 16 | 17 | CLASSIFIERS = [ 18 | 'Development Status :: 4 - Beta', 19 | 'Environment :: Console', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Programming Language :: Python', 23 | 'Programming Language :: Python :: 2', 24 | 'Programming Language :: Python :: 2.7', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.4', 27 | 'Programming Language :: Python :: 3.5', 28 | 'Programming Language :: Python :: 3.6', 29 | 'Topic :: Software Development :: Libraries :: Python Modules', 30 | ] 31 | 32 | version = environ.get('RELEASE') or environ.get('VERSION') or '0.0.0' 33 | 34 | if environ.get('TRAVIS_BUILD_NUMBER'): 35 | version += 'b%s' % environ.get('TRAVIS_BUILD_NUMBER') 36 | 37 | 38 | setup_vals = { 39 | 'name': PROJECT, 40 | 'author': 'LasLabs Inc.', 41 | 'author_email': 'support@laslabs.com', 42 | 'description': SHORT_DESC, 43 | 'url': 'https://laslabs.github.io/python-%s' % PROJECT, 44 | 'download_url': 'https://github.com/LasLabs/python-%s' % PROJECT, 45 | 'license': 'MIT', 46 | 'classifiers': CLASSIFIERS, 47 | 'version': version, 48 | } 49 | 50 | 51 | if path.exists(README_FILE): 52 | with open(README_FILE) as fh: 53 | setup_vals['long_description'] = fh.read() 54 | 55 | 56 | install_requires = [] 57 | if path.exists('requirements.txt'): 58 | with open('requirements.txt') as fh: 59 | install_requires = fh.read().splitlines() 60 | 61 | 62 | class FailTestException(Exception): 63 | """ It provides a failing build """ 64 | pass 65 | 66 | 67 | class Tests(Command): 68 | """ Run test & coverage, save reports as XML """ 69 | 70 | user_options = [] # < For Command API compatibility 71 | 72 | def initialize_options(self, ): 73 | pass 74 | 75 | def finalize_options(self, ): 76 | pass 77 | 78 | def run(self, ): 79 | loader = TestLoader() 80 | tests = loader.discover('.', 'test_*.py') 81 | t = TextTestRunner(verbosity=1) 82 | res = t.run(tests) 83 | if not res.wasSuccessful(): 84 | raise FailTestException() 85 | 86 | 87 | if __name__ == "__main__": 88 | setup( 89 | packages=find_packages(exclude=('tests')), 90 | cmdclass={'test': Tests}, 91 | tests_require=[ 92 | 'mock', 93 | ], 94 | install_requires=install_requires, 95 | **setup_vals 96 | ) 97 | --------------------------------------------------------------------------------