diff --git a/.flake8 b/.flake8 index 569f842..0481ce7 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,20 @@ [flake8] -exclude=.git,.pytest_cache,venv +exclude = + .git, + __pycache__, + .pytest_cache, + venv, + docs/conf.py, + old, + build, + dist, + .py27, + .py38, + .py37, + .py36, + venv, + .venv +count = true +max-complexity = 10 +max-line-length = 127 +statistics = true diff --git a/.gitignore b/.gitignore index 894a44c..53dae99 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,10 @@ venv/ ENV/ env.bak/ venv.bak/ +.py27 +.py36 +.py37 +.py38 # Spyder project settings .spyderproject diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..92eb2ae --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,34 @@ +----------- + +Release 0.1.3 (released January 23, 2020) +============================================ + +* Refactored get methodology in ``CmixAPIClient`` for use by ``CMixProject`` functions +* Created delete method in ``CmixAPIClient`` for use by ``CMixProject`` functions +* Added functions to ``CmixAPIClient``: + + .. code-block:: python + + get_projects() + get_survey_data_layouts(survey_id) + get_survey_locales(survey_id) + get_survey_sections(survey_id) + get_survey_simulations(survey_id) + get_survey_termination_codes(survey_id) + get_survey_sources(survey_id) + +* Created a ``CMixProject`` class and added functions: + + .. code-block:: python + + CmixProject.delete_group(group_id) + CmixProject.delete_project() + CmixProject.get_full_links() + CmixProject.get_groups() + CmixProject.get_links() + CmixProject.get_locales() + CmixProject.get_markup_files() + CmixProject.get_project() + CmixProject.get_respondent_links() + CmixProject.get_sources() + CmixProject.get_surveys() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed7b6e7..7408668 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ This is an InnerSource python project. It is the work of someone who thought it This repository is maintained by -1. [Bradley Wogsland](@wogsland) +1. Ridley Larsen [@RidleyLarsen](@RidleyLarsen) / [@DynataRidley](@DynataRidley) ### Community Guidelines diff --git a/CmixAPIClient/__version__.py b/CmixAPIClient/__version__.py new file mode 100644 index 0000000..e95b7c5 --- /dev/null +++ b/CmixAPIClient/__version__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +__version__ = '0.1.4' diff --git a/CmixAPIClient/api.py b/CmixAPIClient/api.py index 5e001f0..5843c37 100644 --- a/CmixAPIClient/api.py +++ b/CmixAPIClient/api.py @@ -47,6 +47,14 @@ class CmixAPI(object): + """Base class that is used to provide API bindings for the + **Dynata Survey Authoring (Cmix)** tool. + + To execute calls against the Dynata Survey Authoring API, instantiate this + class, authenticate against the API, and leverage its methods to execute + the applicable API calls. + + """ # valid survey statuses SURVEY_STATUS_DESIGN = 'DESIGN' SURVEY_STATUS_LIVE = 'LIVE' @@ -56,24 +64,95 @@ class CmixAPI(object): SURVEY_PARAMS_STATUS_AFTER = 'statusAfter' def __init__( - self, username=None, password=None, client_id=None, client_secret=None, test=False, timeout=None, *args, **kwargs + self, + username=None, + password=None, + client_id=None, + client_secret=None, + test=False, + timeout=None, + *args, + **kwargs ): + """ + :param username: The username used to authenticate against the API. Defaults + to :obj:`None `, but is required and will raise an exception + if missing. + :type username: :class:`str ` + + :param password: The password used to authenticate against the API. Defaults + to :obj:`None `, but is required and will raise an exception + if missing. + :type password: :class:`str ` + + :param client_id: The unique Client ID that is included in your API + credentials. Defaults to :obj:`None `, but is required and + will raise an exception if missing. + + .. warning:: + + If you do not have a Client ID, but intend to use the API, please + contact your Dynata account executive. + + :type client_id: :class:`str ` + + :param client_secret: The Client Secret that is included in your API + credentials. Defaults to :obj:`None `, but is required and + will raise an exception if missing. + + .. warning:: + + If you do not have a Client ID, but intend to use the API, please + contact your Dynata account executive. + + :type client_secret: :class:`str ` + + :param test: Flag which if ``True`` indicates that the instantiated client + is intended to execute test requests, not actual requests. Defaults to + ``False``. + :type test: :class:`bool ` + + :param timeout: The amount of time to wait before raising a timeout error. + Defaults to 16 seconds. + :type timeout: :class:`int ` + + :raises CmixError: If any authentication details (``username``, ``password``, + ``client_id``, or ``client_secret``) are not supplied. + + """ if None in [username, password, client_id, client_secret]: raise CmixError("All authentication data is required.") + self.username = username self.password = password self.client_id = client_id self.client_secret = client_secret + self.url_type = 'BASE_URL' if test is True: self.url_type = 'TEST_URL' + self.timeout = timeout if timeout is not None else DEFAULT_API_TIMEOUT def check_auth_headers(self): + """Validate that the API instance has been authenticated using + :meth:`.authenticate() ` + + :returns: :obj:`None ` on success + + :raises CmixError: If the API instance has not been authenticated. + """ if self._authentication_headers is None: raise CmixError('The API instance must be authenticated before calling this method.') def authenticate(self, *args, **kwargs): + """Authenticate the API instance against the Dynata Survey Authoring API. + + :returns: :obj:`None ` on success + + :raises CmixError: If authentication fails for any reason. + """ + auth_payload = { "grant_type": "password", "client_id": self.client_id, @@ -106,6 +185,11 @@ def authenticate(self, *args, **kwargs): } def fetch_banner_filter(self, survey_id, question_a, question_b, response_id): + """Returns a :term:`Banner Filter` for a given :term:`survey `. + + :returns: A :class:`dict ` of the JSON representaton of the + :term:`Banner Filter`. + """ self.check_auth_headers() log.debug( 'Requesting banner filter for CMIX survey {}, question A: {}, question B: {}, response ID: {}'.format( @@ -130,27 +214,162 @@ def fetch_banner_filter(self, survey_id, question_a, question_b, response_id): }] } response = requests.post(url, headers=self._authentication_headers, json=payload, timeout=self.timeout) + return response.json() def fetch_raw_results(self, survey_id, payload): - ''' - This calls the CMIX Reporting API 'response-counts' endpoint and returns - the data for all of the questions in the survey. - - The payload is a set of JSON objects only containing a question ID. - eg. [ - {'questionId': 122931}, - {...} - ] - ''' + """Retrieve data collected through the survey indicated by ``survey_id``. + + :param survey_id: The unique identifier of the survey whose raw results + should be retrieved. + + :param payload: A collection of :class:`dict ` objects where each + object provides the identifier of a question within the survey whose results + should be returned. The keys expected are: + + * ``testYn``: Indicates whether to return data from respondent records + collected from real/live data collection or from simulated/test data. + Accepts either ``LIVE`` or ``TEST``, respectively. + * ``status``: Indicates whether to return data from respondents who + successfully completed the survey or respondents who were terminated + before completion. Accepts either ``COMPLETE`` or ``TERMINATE``, + respectively. + * ``counts``: A collection of :class:`dict ` objects that + determines those survey questions for which result data should be + returned. Each :class:`dict ` should have a key: + + * ``questionId``: The unique identifier of the survey question. + + * ``filters`` (*optional*): A collection of :class:`dict ` + objects where each object indicates a filter to apply to the data + points returned by the API. Each :class:`dict ` requires + two keys: + + * ``variableId`` (:class:`int `): the unique ID of the + variable that you wish to use as a filter criteria + * ``responseId`` (:class:`int `): the ID of the + possible response that you wish to include in the result set + + .. note:: + + Corresponds to the ``RPS_ID`` in the result returned. + + For example: + + .. code-block:: python + + { + 'testYN': 'LIVE', + 'status': 'COMPLETE', + 'counts': [ + { 'questionId': 122931 }, + { 'questionId': 123456 }, + ... + ], + 'filters': [ + { + 'variableId': 12345, + 'responseId': 54321 + }, + ... + ] + } + + :type payload: :class:`list ` of :class:`dict ` + + :returns: A collection of :class:`dict ` objects where each + object represents a single :term:`data point ` collected + in the survey from a survey question indicated in the ``payload``. Keys + returned are: + + * ``questionId``: The unique ID of the survey question that generated + the data point + * ``CNT``: The raw unweighted number of respondents that answered the + survey question with this response. + + .. caution:: + + Respondents may be double-counted within a single question if the + question supports the simultaneous selection of multiple responses, + therefore calculating the sum of ``CNT`` values is not an appropriate + operation for most use cases. + + * ``NAME``: The name for the type of question that was presented to the + respondent. May contain the following values: + + * TBD + + .. todo:: + + * Populate the list of possible values. + + .. note:: + + This is **not** the "question name" or "survey variable code". + + * ``DECIMAL``: The percentage of respondents who answered the question + with this response, expressed as a decimal value in the range + ``0 - 1``. + * ``STR_VALUE``: For questions that have textual response options, the + single response represented by the data point. For example, for a question + related to "Gender", one ``STR_VALUE`` of a data point may be "Male". + + .. note:: + + * If the response selected was "Other" with the option of inputting + a custom text value, such a custom text value will appear in the + ``STR_VALUE`` field for the question. + * If a text value is inappropriate for the question type (e.g. if + the question type is numeric), will be :obj:`None `. + + * ``NUM_VALUE``: For questions that have ``NUMERIC`` as their type, this + value will contain the numeric value of a response. + * ``RPS_ID``: The identifier for the data point. + + .. caution:: + + These identifiers are not unique and are not sortable. + + * ``TOTAL``: The total number of respondents who responded to the question. + + .. todo:: + + Determine if this corresponds to the total number of respondents in + general (vs. excluding those who may have had this question skipped + due to survey logic) + + :rtype: :class:`list ` of :class:`dict ` + + """ self.check_auth_headers() + log.debug('Requesting raw results for CMIX survey {}'.format(survey_id)) base_url = CMIX_SERVICES['reporting'][self.url_type] url = '{}/surveys/{}/response-counts'.format(base_url, survey_id) response = requests.post(url, headers=self._authentication_headers, json=payload, timeout=self.timeout) + return response.json() - def api_get(self, endpoint, error=''): + def api_get(self, + endpoint, + error=''): + """Execute a GET request against the Dynata Survey Authoring API. + + :param endpoint: The API endpoint that should be retrieved. + :type endpoint: :class:`str ` + + :param error: The heading of the error to return in the error message if + the API request fails. Defaults to + `CMIX returned a non-200 response code`. + :type error: :class:`str ` + + :returns: A Python representation of the JSON object returned by the API. + :rtype: :class:`dict ` or :class:`list ` + + :raises CmixError: if the API returned a response with an HTTP Status + other than 200. + + """ self.check_auth_headers() url = '{}/{}'.format(CMIX_SERVICES['survey'][self.url_type], endpoint) response = requests.get(url, headers=self._authentication_headers, timeout=self.timeout) @@ -167,6 +386,24 @@ def api_get(self, endpoint, error=''): return response.json() def api_delete(self, endpoint, error=''): + """Execute a DELETE request against the Dynata Survey Authoring API. + + :param endpoint: The API endpoint that should be called with a DELETE + request. + :type endpoint: :class:`str ` + + :param error: The heading of the error to return in the error message if + the API request fails. Defaults to + `CMIX returned a non-200 response code`. + :type error: :class:`str ` + + :returns: A Python representation of the JSON object returned by the API. + :rtype: :class:`dict ` or :class:`list ` + + :raises CmixError: if the API returned a response with an HTTP Status + other than 200. + + """ self.check_auth_headers() url = '{}/{}'.format(CMIX_SERVICES['survey'][self.url_type], endpoint) response = requests.delete(url, headers=self._authentication_headers, timeout=self.timeout) @@ -183,30 +420,112 @@ def api_delete(self, endpoint, error=''): return response.json() def get_surveys(self, status, *args, **kwargs): - '''kwargs: + """Retrieve surveys from the Dynata Survey Authoring platform. - extra_params: array of additional url params added to the end of the - url after the 'status' param, they should be passed in as formatted - strings like this: - params = ['paramKey1=paramValue1', 'paramKey2=paramValue2'] - get_surveys('status', extra_params=params) - ''' + :param status: The status of surveys to retrieve. Accepts either: + * ``closed`` for surveys that have finished data collection, + * ``live`` for surveys that have been launched and are collecting data, + * ``design`` for surveys that have not yet been launched + :type status: :class:`str ` + + :param extra_params: Optional collection of URL parameters that should + be added to the API endpoint URL after the ``status`` parameter. + Expects each parameter to be supplied as a string like ``KEY=VALUE``. + + Example: + + .. code-block:: python + + extra_params = ['paramKey1=paramValue1', 'paramKey2=paramValue2'] + + :type extra_params: :class:`list ` of :class:`str ` + + :returns: A collection of :term:`surveys ` that meet the + criteria supplied to the method, where each survey is represented as a + :class:`dict ` with the following keys: + + * ``id``: The unique ID of the survey + * ``name``: The human-readable name given to the survey + * ``token``: A token for the survey + * ``mxrId``: An internal ID used by the Dynata Survey Authoring tool + * ``cxNumber``: TBD + * ``libraryYn``: TBD + * ``clientId``: TBD + * ``primaryProgrammerId``: The ID of the user assigned as the primary + author of the survey. + * ``secondaryProgrammerId``: The ID of the user assigned as the + secondary author of the survey. + * ``status``: The status of the survey. + + .. todo:: + + Confirm the documentation of the keys marked as "TBD" + + :rtype: :class:`list ` of :class:`dict ` + + """ self.check_auth_headers() base_url = CMIX_SERVICES['survey'][self.url_type] surveys_url = '{}/surveys?status={}'.format(base_url, status) extra_params = kwargs.get('extra_params') if extra_params is not None: surveys_url = self.add_extra_url_params(surveys_url, extra_params) + surveys_response = requests.get(surveys_url, headers=self._authentication_headers, timeout=self.timeout) + return surveys_response.json() def add_extra_url_params(self, url, params): + """Appends additional URL parameters to the ``url`` supplied. + + :param url: The URL to which parameters should be appended. + :type url: :class:`str ` + + :param params: Collection of URL parameters that should + be appended to ``url``. Expects each parameter to be supplied as a + string like ``KEY=VALUE``. + + Example: + + .. code-block:: python + + params = ['paramKey1=paramValue1', 'paramKey2=paramValue2'] + + :type params: iterable of :class:`str ` + + :rtype: :class:`str ` + + """ for param in params: url = '{}&{}'.format(url, param) return url def get_survey_data_layouts(self, survey_id): + """Retrieve the :term:`Data Layouts ` for a given survey. + + :param survey_id: The unique ID of the survey whose data layouts should + be retrieved. + + :returns: Collection of data layout objects in :class:`dict ` + form with the following keys: + + * ``id``: The identifier of the :term:`Data Layout` + * ``surveyId``: The unique identifier of the survey. + * ``userId``: TBD + * ``name``: TBD + * ``deletedYn``: TBD + + .. todo:: + + Confirm the documentation of the keys marked as "TBD" + + :rtype: :class:`list ` of :class:`dict ` + + :raises CmixError: if the API returns an HTTP Status code other than + ``200`` + + """ self.check_auth_headers() data_layouts_url = '{}/surveys/{}/data-layouts'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) data_layouts_response = requests.get(data_layouts_url, headers=self._authentication_headers, timeout=self.timeout) @@ -220,24 +539,65 @@ def get_survey_data_layouts(self, survey_id): return data_layouts_response.json() def get_survey_definition(self, survey_id): + """Retrieve the :term:`Survey Definition` for the survey indicated. + + :param survey_id: The unique ID of the survey whose definition should be + retrieved. + + :returns: TBD + + .. todo:: + + Determine what this method returns. + + """ self.check_auth_headers() definition_url = '{}/surveys/{}/definition'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) definition_response = requests.get(definition_url, headers=self._authentication_headers, timeout=self.timeout) return definition_response.json() def get_survey_xml(self, survey_id): + """Retrieve the source code (:term:`Survey Definition`) for the indicated + survey. + + :param survey_id: The unique ID of the survey whose definition should be + retrieved. + + :returns: The source code for the :term:`Survey Definition`. + + .. seealso:: + + * :doc:`The Survey Definition ` + + :rtype: :class:`str ` containing a + :doc:`Survey Definition ` + + """ self.check_auth_headers() xml_url = '{}/surveys/{}'.format(CMIX_SERVICES['file'][self.url_type], survey_id) xml_response = requests.get(xml_url, headers=self._authentication_headers, timeout=self.timeout) return xml_response.content def get_survey_test_url(self, survey_id): + """Retrieve the :term:`Test Link` which would allow you to test the + indicated survey. + + :param survey_id: The unique ID of the survey whose :term:`Test Link` + should be retrieved. + + :returns: The URL which can be used to test the survey indicated by + ``survey_id``. + :rtype: :class:`str ` + + :raises CmixError: if no :term:`Test Token` is returned + """ self.check_auth_headers() survey_url = '{}/surveys/{}'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) survey_response = requests.get(survey_url, headers=self._authentication_headers, timeout=self.timeout) test_token = survey_response.json().get('testToken', None) if test_token is None: raise CmixError('Survey endpoint for CMIX ID {} did not return a test token.'.format(survey_id)) + test_link = '{}/#/?cmixSvy={}&cmixTest={}'.format( CMIX_SERVICES['test'][self.url_type], survey_id, @@ -245,7 +605,60 @@ def get_survey_test_url(self, survey_id): ) return test_link - def get_survey_respondents(self, survey_id, respondent_type, live): + def get_survey_respondents(self, survey_id, respondent_type, live=False): + """Retrieve metadata about survey :term:`respondents ` for + the indicated survey. + + :param survey_id: The unique ID of the survey whose + :term:`respondent ` meta-data should be retrieved. + :type survey_id: :class:`int ` + + :param respondent_type: TBD + + .. todo:: + + Determine what this parameter represents. + + :type respondent_type: :class:`str ` + + :param live: If ``True`` returns :term:`respondent ` meta-data + from respondents who filled out the "live" (launched) survey. Otherwise, + returns meta-data for respondents who filled out the "test" (pre-launch) + survey. Defaults to ``False``. + :type live: :class:`bool ` + + :returns: Collection of meta-data describing :term:`respondents ` + who meet the filtering criteria supplied to the method. Each object in + the collection is a :class:`dict ` with the following keys: + + * ``id``: The unique ID of the :term:`Respondent` + * ``status``: The status of the :term:`Respondent` + * ``terminationCodeId``: The unique ID of the :term:`Termination Code` + with which the :term:`Respondent` ended the survey + * ``startDate``: The timestamp for when the :term:`Respondent` began + the survey + * ``endDate``: The timestamp for when the :term:`Respondent` finished + the survey + * ``test``: Boolean flag indicating whether the respondent data is + test or simulated data + * ``fingerprint``: TBD + * ``localeId``: The unique ID of the :term:`locale ` within + which the :term:`Respondent` took the survey + * ``pageId``: TBD + * ``quotaCellId``: TBD + * ``quotaRowId``: TBD + * ``quotaId``: TBD + * ``surveyId``: TBD + * ``surveySampleSourceId``: TBD + * ``token``: TBD + + .. todo:: + + Determine the meaning of each key marked TBD + + :rtype: :class:`list ` of :class:`dict ` + + """ self.check_auth_headers() respondents_url = '{}/surveys/{}/respondents?respondentType={}&respondentStatus={}'.format( CMIX_SERVICES['reporting'][self.url_type], @@ -257,6 +670,28 @@ def get_survey_respondents(self, survey_id, respondent_type, live): return respondents_response.json() def get_survey_locales(self, survey_id): + """Retrieve the :term:`locales ` defined for the indicated survey. + + :param survey_id: The unique ID of the survey whose + :term:`locales ` should be retrieved. + + :returns: Collection of :term:`Locale` objects as + :class:`dict ` where each object contains the following + keys: + + * ``id``: The unique ID of the :term:`Locale` + * ``surveyId``: The unique ID of the survey + * ``isoCode``: The ISO code of the :term:`Locale` + * ``name``: The human-readable name of the :term:`Locale` + * ``default``: If ``True``, indicates that this is the default + :term:`Locale` to apply to the survey + * ``active``: If ``True``, indicates that the :term:`Locale` is active + or supported by the survey + :rtype: :class:`list ` of :class:`dict ` + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ self.check_auth_headers() locales_url = '{}/surveys/{}/locales'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) locales_response = requests.get(locales_url, headers=self._authentication_headers, timeout=self.timeout) @@ -270,15 +705,61 @@ def get_survey_locales(self, survey_id): return locales_response.json() def get_survey_status(self, survey_id): + """Retrieve the status of a survey. + + :param survey_id: The unique ID of the survey whose status should be + retrieved. + + :returns: The status of the survey. Should return either: + + * ``design`` for surveys that have not yet been launched (are not yet + collecting data) + * ``live`` for surveys that have been launched and are collecting data + * ``closed`` for surveys that are no longer collecting data + + :rtype: :class:`str ` + + :raises CmixError: if no status is available for the survey + """ self.check_auth_headers() status_url = '{}/surveys/{}'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) status_response = requests.get(status_url, headers=self._authentication_headers, timeout=self.timeout) status = status_response.json().get('status', None) if status is None: raise CmixError('Get Survey Status returned without a status. Response: {}'.format(status_response.json())) + return status.lower() def get_survey_sections(self, survey_id): + """Retrieve meta-data about the :term:`sections ` of the + survey indicated. + + :param survey_id: The unique ID of the survey whose + :term:`section ` meta-data should be retrieved. + :type survey_id: :class:`int ` + + :returns: Collection of :term:`Survey Section` objects as + :class:`dict ` with keys: + + * ``id``: The unique ID of the :term:`Survey Section` + * ``surveyId``: The unique ID of the survey + * ``name``: The human-readable name of the :term:`Survey Section` + * ``ordinal``: The ordinal position of the section among all sections + within the survey + * ``description``: The description given to the section + * ``settings``: A dictionary with settings applied to the section + * ``label``: A human-readable label to apply to the section + * ``existingSectionId``: TBD + + .. todo:: + + Determine the keys marked TBD + + :rtype: :class:`list ` of :class:`dict ` + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ self.check_auth_headers() sections_url = '{}/surveys/{}/sections'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) sections_response = requests.get(sections_url, headers=self._authentication_headers, timeout=self.timeout) @@ -292,6 +773,28 @@ def get_survey_sections(self, survey_id): return sections_response.json() def get_survey_sources(self, survey_id): + """Retrieve the sources for the survey. + + .. todo:: + + Confirm that "survey sources" means the sample sources. + + :param survey_id: The unique ID of the survey whose sources should be + retrieved. + :type survey_id: :class:`int ` + + :returns: Collection of :term:`Survey Source` objects as + :class:`dict ` objects with keys: + + * ``id``: The unique ID of the :term:`Survey Source` + * ``name``: The human-readable name of the :term:`Survey Source` + * ``token``: TBD + + .. todo:: + + Determine the meaning of the keys marked TBD + + """ self.check_auth_headers() sources_url = '{}/surveys/{}/sources'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) sources_response = requests.get(sources_url, headers=self._authentication_headers, timeout=self.timeout) @@ -305,9 +808,88 @@ def get_survey_sources(self, survey_id): return sources_response.json() def get_survey_completes(self, survey_id): + """Retrieve the metadata for COMPLETE respondent records. + + .. note:: + + This method is equivalent to calling: + + .. code-block:: python + + .get_survey_respondents(survey_id, + respondentType = 'COMPLETE', + live = True) + + :param survey_id: The unique ID of the survey whose + :term:`respondent ` meta-data should be retrieved. + :type survey_id: :class:`int ` + + :returns: Collection of meta-data describing :term:`respondents ` + completed the survey following its launch. Each object in the + collection is a :class:`dict ` with the following keys: + + * ``id``: The unique ID of the :term:`Respondent` + * ``status``: The status of the :term:`Respondent` + * ``terminationCodeId``: The unique ID of the :term:`Termination Code` + with which the :term:`Respondent` ended the survey + * ``startDate``: The timestamp for when the :term:`Respondent` began + the survey + * ``endDate``: The timestamp for when the :term:`Respondent` finished + the survey + * ``test``: Boolean flag indicating whether the respondent data is + test or simulated data + * ``fingerprint``: TBD + * ``localeId``: The unique ID of the :term:`locale ` within + which the :term:`Respondent` took the survey + * ``pageId``: TBD + * ``quotaCellId``: TBD + * ``quotaRowId``: TBD + * ``quotaId``: TBD + * ``surveyId``: TBD + * ``surveySampleSourceId``: TBD + * ``token``: TBD + + .. todo:: + + Determine the meaning of each key marked TBD + + :rtype: :class:`list ` of :class:`dict ` + + .. seealso:: + + * :meth:`.get_survey_respondents() ` + + """ + return self.get_survey_respondents(survey_id, "COMPLETE", True) def get_survey_termination_codes(self, survey_id): + """Retrieve the :term:`Termination Codes ` defined for + the indicated survey. + + :param survey_id: The unique ID of the survey whose + :term:`Termination Codes ` should be retrieved. + :type survey_id: :class:`int ` + + :returns: Collection of :term:`Termination Code` objects represented as + :class:`dict ` objects with keys: + + * ``id``: Unique ID of the :term:`Termination Code` + * ``surveyId``: Unique ID of the survey + * ``name``: Name assigned to the :term:`Termination Code` + * ``type``: TBD + * ``questionId``: The unique ID of the question with which the + :term:`Termination Code` is associated + + .. todo:: + + Determine the keys marked TBD + + :rtype: :class:`list ` of :class:`dict ` + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ self.check_auth_headers() termination_codes_url = '{}/surveys/{}/termination-codes'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) termination_codes_response = requests.get( @@ -325,6 +907,62 @@ def get_survey_termination_codes(self, survey_id): return termination_codes_response.json() def create_export_archive(self, survey_id, export_type): + """Generate a data file of respondent records from the indicated + survey. + + :param survey_id: The unique ID of the survey whose respondent data + should be converted into a downloadable data file. + :type survey_id: :class:`int ` + + :param export_type: The type of data file that should be produced. + Accepts: ``SPSS`` or ``csv`` + + .. todo:: + + Confirm meaning and acceptable values for ``export_type`` + + :type export_type: :class:`str ` + + :returns: Meta-data about the data file produced, returned as a + :class:`dict ` with the following keys: + + * ``id``: Unique ID of the data file + * ``dataLayoutId``: unique ID of the :term:`Data Layout` applied to + the data file + * ``surveyId``: the unique ID of the survey + * ``progress``: TBD + * ``filterStartDate``: the timestamp on or after which respondent records + who started taking the survey would be included in the data file + * ``filterEndDate``: the timestamp on or before which respondent records + who finished taking the survey would be included in the data file + * ``respondentType``: the type of respondent records contained in the + data file + * ``fileName``: the filename of the data file + * ``type``: the file type of the data file + * ``status``: the status of the data file + * ``archiveUrl``: the URL from which the data file may be retrieved + * ``deletedYn``: TBD + * ``completes``: if ``True``, indicates that the data file contains + COMPLETE records (i.e. records where the respondent finished the + survey) + * ``inProcess``: if ``True``, indicates that the data file contains + PARTIAL records (i.e. records where the respondent has begun but not + yet finished the survey) + * ``terminates``: if ``True``, indicates that the data file contains + TERMINATED records (i.e. records where the respondent was exited + from the survey based on the survey logic) + + :rtype: :class:`dict ` + + :raises CmixError: if the API returned an HTTP Status Code other than + ``200`` when generating the data file + + :raises CmixError: if an error occurred when generating the data file + + :raises CmixError: if the survey did not have a default + :term:`Data Layout` + + """ self.check_auth_headers() archive_url = '{}/surveys/{}/archives'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) headers = self._authentication_headers.copy() @@ -365,15 +1003,68 @@ def create_export_archive(self, survey_id, export_type): ) archive_json['dataLayoutId'] = layout_id + return archive_json def get_archive_status(self, survey_id, archive_id, layout_id): + """Retrieve the meta-data for a data file export (data archive). + + :param survey_id: The unique ID of the survey whose archive meta-data + should be retrieved. + :type survey_id: :class:`int ` + + :param archive_id: The unique ID of the data file whose meta-data should + be retrieved. + :type archive_id: :class:`int ` + + :param layout_id: The unique ID of the data layout applied to the data + file whose meta-data should be retrieved. + :type layout_id: :class:`int ` + + :returns: Meta-data about the data file produced, returned as a + :class:`dict ` with the following keys: + + * ``id``: Unique ID of the data file + * ``dataLayoutId``: unique ID of the :term:`Data Layout` applied to + the data file + * ``surveyId``: the unique ID of the survey + * ``progress``: TBD + * ``filterStartDate``: the timestamp on or after which respondent records + who started taking the survey would be included in the data file + * ``filterEndDate``: the timestamp on or before which respondent records + who finished taking the survey would be included in the data file + * ``respondentType``: the type of respondent records contained in the + data file + * ``fileName``: the filename of the data file + * ``type``: the file type of the data file + * ``status``: the status of the data file + * ``archiveUrl``: the URL from which the data file may be retrieved + * ``deletedYn``: TBD + * ``completes``: if ``True``, indicates that the data file contains + COMPLETE records (i.e. records where the respondent finished the + survey) + * ``inProcess``: if ``True``, indicates that the data file contains + PARTIAL records (i.e. records where the respondent has begun but not + yet finished the survey) + * ``terminates``: if ``True``, indicates that the data file contains + TERMINATED records (i.e. records where the respondent was exited + from the survey based on the survey logic) + + :rtype: :class:`dict ` + + :raises CmixError: if ``layout_id`` is :obj:`None ` + + :raises CmixError: if ``archive_id`` is :obj:`None ` + + :raises CmixError: if the API returned an HTTP Status Code other than ``200`` + + """ self.check_auth_headers() if layout_id is None: - raise CmixError('Error while updating archie status: layout ID is None. Archive ID: {}'.format(archive_id)) + raise CmixError('Error while updating archive status: layout ID is None. Archive ID: {}'.format(archive_id)) if archive_id is None: raise CmixError( - 'Error while updating archie status: CMIX archive ID is None. Pop Archive ID: {}'.format(archive_id) + 'Error while updating archive status: CMIX archive ID is None. Archive ID: {}'.format(archive_id) ) base_url = CMIX_SERVICES['survey'][self.url_type] archive_url = '{}/surveys/{}/data-layouts/{}/archives/{}'.format( @@ -390,12 +1081,38 @@ def get_archive_status(self, survey_id, archive_id, layout_id): archive_response.text ) ) + return archive_response.json() def update_project(self, project_id, status=None): - ''' - NOTE: This endpoint accepts a project ID, not a survey ID. - ''' + """Update the status of a survey project. + + :param project_id: The unique ID of the :term:`Project` whose status + should be updated. + :type project_id: :class:`int ` + + :param status: The status that should be applied to the :term:`Project`. + Defaults to :obj:`None `. Accepts: + + * ``LIVE`` to launch the survey (start data collection) + * ``CLOSED`` to close/finish the survey (end data collection) + * ``DESIGN`` to switch the survey to design-mode + + .. todo:: + + Confirm acceptable values. + + :type status: :class:`str ` + + :returns: The :class:`requests.Response ` object for the API + request + :rtype: :class:`requests.Response ` + + :raises CmixError: if ``status`` was empty + + :raises CmixError: if the API returns an HTTP Status code greater than `299` + + """ self.check_auth_headers() payload_json = {} @@ -407,6 +1124,7 @@ def update_project(self, project_id, status=None): url = '{}/projects/{}'.format(CMIX_SERVICES['survey'][self.url_type], project_id) response = requests.patch(url, json=payload_json, headers=self._authentication_headers, timeout=self.timeout) + if response.status_code > 299: raise CmixError( 'CMIX returned an invalid response code during project update: HTTP {} and error {}'.format( @@ -417,14 +1135,37 @@ def update_project(self, project_id, status=None): return response def create_survey(self, xml_string): - ''' - This function will create a survey on CMIX and set the survey's status to 'LIVE'. - ''' + """Create a survey and set its status to ``LIVE``. + + .. todo:: + + Verify whether this is actually what this funciton does. Looking at + the Python source code, it seems that it sets the survey's status to + ``DESIGN`` + + :param xml_string: A complete definition of the survey in XML format. + For more information, please see :doc:`The Survey Definition `. + + .. seealso:: + + * :doc:`The Survey Definition ` + + :type xml_string: :class:`str ` that validates + to :doc:`the Survey Definition ` + + :returns: The :class:`requests.Response ` object for the API + request + :rtype: :class:`requests.Response ` + + :raises CmixError: if the API returns an HTTP Status Code greater than ``299`` + + """ self.check_auth_headers() url = '{}/surveys/data'.format(CMIX_SERVICES['file'][self.url_type]) payload = {"data": xml_string} response = requests.post(url, payload, headers=self._authentication_headers, timeout=self.timeout) + if response.status_code > 299: raise CmixError( 'Error while creating survey. CMIX responded with status' + @@ -435,13 +1176,55 @@ def create_survey(self, xml_string): ) ) response_json = response.json() - self.update_project(response_json.get('projectId'), status=self.SURVEY_STATUS_DESIGN) + + self.update_project(response_json.get('projectId'), + status=self.SURVEY_STATUS_DESIGN) + return response_json def get_survey_simulations(self, survey_id): + """Retrieve :term:`Survey Simulations ` meta-data for + the indicated survey. + + :param survey_id: The unique ID of the survey whose simulation meta-data + should be retrieved. + :type survey_id: :class:`int ` + + :returns: Collection of :term:`Survey Simulation` objects represented as + :class:`dict ` with keys: + + * ``id``: the unique ID of the :term:`Survey Simulation` + * ``userId``: the unique ID of the user who generated the simulated + data + * ``surveyId``: the unique ID of the survey + * ``name``: the name given to the simulated dataset + * ``respondentCount``: the number of respondents that were simulated + * ``completesCount``: the number of COMPLETED records that were simulated + * ``terminatesCount``: the number of TERMINATED records that were simualted + * ``dropOutCount``: the number of DROP-OUT (not finished) records that + were simulated + * ``requestedCount``: the number of records that were requested + * ``requestedCompletesCount``: the number of COMPLETED records that + were requested + * ``dateCreated``: the timestamp for when the simulation was created + * ``dateModified``: the timestamp for when the simulation was last modified + * ``template``: TBD + * ``user``: an embedded object describing the user who generated the + simulated dataset + + .. todo:: + + Verify what this function actually returns. + + :rtype: :class:`list ` of :class:`dict ` + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ self.check_auth_headers() simulations_url = '{}/surveys/{}/simulations'.format(CMIX_SERVICES['survey'][self.url_type], survey_id) simulations_response = requests.get(simulations_url, headers=self._authentication_headers, timeout=self.timeout) + if simulations_response.status_code != 200: raise CmixError( 'CMIX returned a non-200 response code while getting simulations: {} and error {}'.format( @@ -449,10 +1232,25 @@ def get_survey_simulations(self, survey_id): simulations_response.text ) ) + return simulations_response.json() def get_projects(self): + """Retrieve a collection of :term:`Projects ` from the + Dynata Survey Authoring system. + + :returns: A collection of :term:`Project` objects with meta-data + represented as :class:`dict ` with keys: + + .. todo:: + + Determine the keys that are returned. + + :rtype: :class:`list ` of :class:`dict ` + + """ project_endpoint = 'projects' project_error = 'CMIX returned a non-200 response code while getting projects' project_response = self.api_get(project_endpoint, project_error) + return project_response diff --git a/CmixAPIClient/error.py b/CmixAPIClient/error.py index ee93304..c8f6ddc 100644 --- a/CmixAPIClient/error.py +++ b/CmixAPIClient/error.py @@ -3,8 +3,10 @@ class CmixError(Exception): - ''' + """ This base error will help determine when CMIX returns a bad response or otherwise raises an exception while using the API. - ''' + + **INHERITS FROM:** :class:`Exception ` + """ pass diff --git a/CmixAPIClient/project.py b/CmixAPIClient/project.py index c8a3beb..a2f6274 100644 --- a/CmixAPIClient/project.py +++ b/CmixAPIClient/project.py @@ -5,73 +5,260 @@ class CmixProject(object): + """An API client that exposes a variety of bindings for interacting with a + given :term:`Project` defined on the Dynata Survey Authoring system. + + """ def __init__(self, client, project_id): + """ + :param client: An authenticated instance of the **Survey API Client**. + :type client: :class:`CmixAPI ` + + :param project_id: The unique ID of the :term:`Project` to associate + with this instance. + :type project_id: :class:`int ` + + :raises CmixError: if either ``client`` or ``project_id`` are not + supplied + + """ if None in [client, project_id]: raise CmixError("Client and project id are required.") self.client = client self.project_id = project_id def delete_project(self): + """Deletes the :term:`project ` from the system. + + .. warning:: BE CAREFUL! + + This operation cannot be undone! + + :returns: The :class:`requests.Response ` object for the API + request + :rtype: :class:`requests.Response ` + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ + project_endpoint = 'projects/{}'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while deleting project' project_response = self.client.api_delete(project_endpoint, project_error) return project_response def delete_group(self, group_id): + """Deletes a :term:`Group` from the system. + + .. warning:: BE CAREFUL! + + This operation cannot be undone! + + :returns: The :class:`requests.Response ` object for the API + request + :rtype: :class:`requests.Response ` + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ project_endpoint = 'projects/{}/groups/{}'.format(self.project_id, group_id) project_error = 'CMIX returned a non-200 response code while deleting group' project_response = self.client.api_delete(project_endpoint, project_error) return project_response def get_project(self): + """Retrieves meta-data about the :term:`Project`. + + :returns: A :term:`Project` meta-data object represented as a + :class:`dict ` with keys: + + .. todo:: + + Determine the keys returned. + :rtype: :class:`dict ` + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ project_endpoint = 'projects/{}'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while getting project' project_response = self.client.api_get(project_endpoint, project_error) return project_response def get_sources(self): + """Retrieve the sources for the :term:`project `. + + .. todo:: + + Confirm that "project sources" means the sample sources. + + :returns: Collection of :term:`Survey Source` objects as + :class:`dict ` objects with keys: + + * ``id``: The unique ID of the :term:`Survey Source` + * ``name``: The human-readable name of the :term:`Survey Source` + * ``token``: TBD + + .. todo:: + + Determine the meaning of the keys marked TBD + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ project_endpoint = 'projects/{}/sources'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while getting project sources' project_response = self.client.api_get(project_endpoint, project_error) return project_response def get_groups(self): + """Retrieve :term:`Groups ` defined for the :term:`project `. + + :returns: Collection of :term:`Group` objects as + :class:`dict ` objects with keys: + + .. todo:: + + Determine the keys returned + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ project_endpoint = 'projects/{}/groups'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while getting project groups' project_response = self.client.api_get(project_endpoint, project_error) return project_response def get_links(self): + """Retrieve :term:`Links ` for the :term:`Project`. + + :returns: Collection of :term:`Link` objects as + :class:`dict ` objects with keys: + + .. todo:: + + Determine the keys returned + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ project_endpoint = 'projects/{}/links'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while getting project links' project_response = self.client.api_get(project_endpoint, project_error) return project_response def get_full_links(self): + """Retrieve :term:`Links ` for the :term:`Project`. + + .. todo:: + + What is the difference between this and ``get_links()`` ? + + :returns: Collection of :term:`Link` objects as + :class:`dict ` objects with keys: + + .. todo:: + + Determine the keys returned + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ project_endpoint = 'projects/{}/full-links'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while getting project full links' project_response = self.client.api_get(project_endpoint, project_error) return project_response def get_locales(self): + """Retrieve the :term:`locales ` defined for the :term:`Project`. + + :returns: Collection of :term:`Locale` objects as + :class:`dict ` where each object contains the following + keys: + + * ``id``: The unique ID of the :term:`Locale` + * ``surveyId``: The unique ID of the survey + * ``isoCode``: The ISO code of the :term:`Locale` + * ``name``: The human-readable name of the :term:`Locale` + * ``default``: If ``True``, indicates that this is the default + :term:`Locale` to apply to the survey + * ``active``: If ``True``, indicates that the :term:`Locale` is active + or supported by the survey + :rtype: :class:`list ` of :class:`dict ` + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ project_endpoint = 'projects/{}/locales'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while getting project locales' project_response = self.client.api_get(project_endpoint, project_error) return project_response def get_markup_files(self): + """Retrieve the :term:`Markup Files` for the :term:`Project`. + + :returns: TBD + + .. todo:: + + Determine what gets returned. + + :rtype: TBD + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ project_endpoint = 'projects/{}/markup-files'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while getting project markup files' project_response = self.client.api_get(project_endpoint, project_error) + return project_response def get_respondent_links(self): + """Retrieve the :term:`Respondent Links` for the :term:`Project`. + + :returns: Collection of :term:`Link` objects as + :class:`dict ` objects with keys: + + .. todo:: + + Determine the keys returned + + :raises CmixError: if the API returns an HTTP Status Code other than ``200`` + + """ project_endpoint = 'projects/{}/respondent-links'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while getting project respondent links' project_response = self.client.api_get(project_endpoint, project_error) return project_response def get_surveys(self): + """Retrieve surveys associated with the :term:`Project`. + + :returns: A collection of :term:`surveys ` associated with the + :term:`Project`, where each survey is represented as a + :class:`dict ` with the following keys: + + * ``id``: The unique ID of the survey + * ``name``: The human-readable name given to the survey + * ``token``: A token for the survey + * ``mxrId``: An internal ID used by the Dynata Survey Authoring tool + * ``cxNumber``: TBD + * ``libraryYn``: TBD + * ``clientId``: TBD + * ``primaryProgrammerId``: The ID of the user assigned as the primary + author of the survey. + * ``secondaryProgrammerId``: The ID of the user assigned as the + secondary author of the survey. + * ``status``: The status of the survey. + + .. todo:: + + Confirm the documentation of the keys marked as "TBD" + + :rtype: :class:`list ` of :class:`dict ` + + """ project_endpoint = 'projects/{}/surveys'.format(self.project_id) project_error = 'CMIX returned a non-200 response code while getting project surveys' project_response = self.client.api_get(project_endpoint, project_error) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_contributors.rst b/docs/_contributors.rst new file mode 100644 index 0000000..6e25e8f --- /dev/null +++ b/docs/_contributors.rst @@ -0,0 +1,3 @@ +* **PRINCIPLE MAINTAINER:** Ridley Larsen (`@RidleyLarsen `_ / `@DynataRidley `_) +* Chris Modzelewski (`@cmodzelewski-dynata `_) +* Bradley Wogsland (`@wogsland `_ / `@dynata-bradley `_) diff --git a/docs/_dependencies.rst b/docs/_dependencies.rst new file mode 100644 index 0000000..3c1ee05 --- /dev/null +++ b/docs/_dependencies.rst @@ -0,0 +1 @@ +* `requests v.2.22.0 `_ for HTTP Requests diff --git a/docs/about.rst b/docs/about.rst new file mode 100644 index 0000000..377a2c0 --- /dev/null +++ b/docs/about.rst @@ -0,0 +1,66 @@ +####################################### +About Dynata Survey Authoring (Cmix) +####################################### + +.. contents:: + :local: + :depth: 2 + :backlinks: entry + +---------- + +************************************ +What is Dynata Survey Authoring? +************************************ + +**Dynata Survey Authoring** is the survey authoring tool within the +`Dynata Insights Platform `_. +Hundreds of companies all around the world use it to design and manage +cutting-edge surveys delivered to respondents via web and mobile platforms. + +.. raw:: html + +
+ +
+ +.. note:: + + **Dynata Survey Authoring** was originally developed under the brand name + "Cmix" by Critical Mix, a member of the Reimagine Group that was acquired by + Dynata in early 2019. + +Sample Support +================= + +Dynata Survey Authoring is seamlessly integrated with +**Dynata Audience Solutions**, allowing you to easily tap into the industry's +largest collection of first-party data, gaining access to Dynata's over 62 million +consumer and B2B respondents. + +Reporting & Analytics +============================ + +Dynata Survey Authoring is also seamlessly integrated with +**Dynata Reporting & Analytics** (a.k.a. +`MarketSight `_) to give you easy access to robust +point-and-click data analytics, data visualization, and interactive dashboarding. + +--------------------- + +*************************** +How Can I Learn More? +*************************** + +To Contact Dynata +==================== + +The best way to learn more about **Dynata Survey Authoring** is to reach out +to Dynata. You can contact us at: https://www.dynata.com/company/contact/ + +To Learn About Using Dynata Survey Authoring +================================================ + +We encourage you to review our comprehensive training videos available at the +`Dynata YouTube Channel `_ available at: +https://videos.dynata.com diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..0aa1d6e --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,78 @@ +############################# +API Reference +############################# + +.. contents:: + :depth: 3 + :backlinks: entry + +--------------- + +************************************************** +Instantiating the API Client and Authenticating +************************************************** + +To execute requests against the **Dynata Survey Authoring** API, you must first +instantiate an API client. This is done by creating an instance of a +:class:`CmixAPI` object, and calling the +:meth:`.authenticate() ` method to authenticate against +the API. + +For example: + +.. code-block:: python + + from CmixAPIClient.api import CmixAPI + + # 1. Initialize an instance of the DSA Python library with your authentication + # credentials. + cmix = CmixAPI( + username="test_username", + password="test_password", + client_id="test_client_id", + client_secret="test_client_secret" + ) + + # 2. Authenticate against the API. + cmix.authenticate() + + # 3. Execute whatever API calls you need to execute. + surveys = cmix.get_surveys('closed') + +----------- + + +********************* +Core API Client +********************* + +.. _survey_api_client: + +The **Core API Client** is the primary API client for interacting with the +**Dynata Survey Authoring** system's API. + +.. module:: CmixAPIClient.api + +.. autoclass:: CmixAPI + :members: + +------------------------ + +***************************** +Project API Client +***************************** + +The **Project API Client** is the primary API client for interacting with +individual :term:`Project` configurations within the Dynata Survey Authoring +system. + +.. note:: + + As the documentation below makes clear, in order to instantiate and make use + of the **Project API Client**, you have to first instantiate and authenticate + a :ref:`Core API Client ` instance. + +.. module:: CmixAPIClient.project + +.. autoclass:: CmixProject + :members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..f2d8574 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,123 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +import os +import sys +sys.path.insert(0, os.path.join(os.path.dirname(__name__), '..')) + +import sphinx_rtd_theme + +# Load Version Information +version_dict = {} +with open(os.path.join(os.path.dirname(__file__), + '../', + 'CmixAPIClient', + '__version__.py')) as version_file: + exec(version_file.read(), version_dict) # pylint: disable=W0122 + +__version__ = version_dict.get('__version__') + +project = 'Dynata Survey Authoring (Cmix) Python Client' +copyright = '2020, Dynata, LLC' +author = 'Dynata, LLC' + +# The short X.Y version +version = __version__[:3] +# The full version, including alpha/beta/rc tags +release = __version__ + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.ifconfig', + 'sphinx.ext.viewcode', + 'sphinx.ext.githubpages', + 'sphinx_tabs.tabs', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# The name of the Pygments (syntax highlighting) style to use. +#pygments_style = 'sphinx' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Theme Options for configuration of the Sphinx ReadTheDocs Theme. +html_theme_options = { + 'navigation_depth': 4, + 'display_version': True, + 'prev_next_buttons_location': 'both' +} + +# HTML Context for display on ReadTheDocs +html_context = { + "display_github": True, # Integrate GitHub + "github_user": "dynata", # Username + "github_repo": "python-cmixapi-client", # Repo name + "github_version": "master", # Version + "conf_py_path": "/docs/", # Path in the checkout to the docs root +} + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for intersphinx extension --------------------------------------- + +# Autodoc configuration settings. +autoclass_content = 'both' +autodoc_member_order = 'groupwise' +add_module_names = False + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = { + 'python': ('https://docs.python.org/3.8', None), + 'requests': ('https://2.python-requests.org/en/master/', None), + 'flake8': ('https://flake8.pycqa.org/en/master/', None), + 'pycodestyle': ('https://pycodestyle.pycqa.org/en/latest/', None), +} + +# -- Options for todo extension ---------------------------------------------- + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..189f107 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,277 @@ +############################################################### +Contributing to the Dynata Survey Authoring Python Client +############################################################### + +.. note:: + + As a general rule of thumb, the **Dynata Survey Authoring Python Client** + applies :pep:`PEP 8 <8>` styling, with some important differences. + +.. sidebar:: What makes an API idiomatic? + + One of our favorite ways of thinking about idiomatic design comes from a `talk + given by Luciano Ramalho at Pycon 2016`_ where he listed traits of a Pythonic + API as being: + + * don't force [the user] to write boilerplate code + * provide ready to use functions and objects + * don't force [the user] to subclass unless there's a *very good* reason + * include the batteries: make easy tasks easy + * are simple to use but not simplistic: make hard tasks possible + * leverage the Python data model to: + + * provide objects that behave as you expect + * avoid boilerplate through introspection (reflection) and metaprogramming. + + +.. contents:: Contents: + :local: + :depth: 3 + +Design Philosophy +==================== + +The **Dynata Survey Authoring Python Client** is meant to be a "beautiful" and +"usable" library. That means that it should offer an idiomatic API that: + +* works out of the box as intended, +* minimizes "bootstrapping" to produce meaningful output, and +* does not force users to understand how it does what it does. + +In other words: + +.. pull-quote:: + + Users should simply be able to drive the car without looking at the engine. + +Style Guide +================ + +Basic Conventions +------------------- + +* Do not terminate lines with semicolons. +* Line length should have a maximum of *approximately* 90 characters. If in doubt, + make a longer line or break the line between clear concepts. +* Each class should be contained in its own file. +* If a file runs longer than 2,000 lines...it should probably be refactored and + split. +* All imports should occur at the top of the file. + +* Do not use single-line conditions: + + .. code-block:: python + + # GOOD + if x: + do_something() + + # BAD + if x: do_something() + +* When testing if an object has a value, be sure to use ``if x is None:`` or + ``if x is not None``. Do **not** confuse this with ``if x:`` and ``if not x:``. +* Use the ``if x:`` construction for testing truthiness, and ``if not x:`` for + testing falsiness. This is **different** from testing: + + * ``if x is True:`` + * ``if x is False:`` + * ``if x is None:`` + +* As of right now, because we feel that it negatively impacts readability and is + less-widely used in the community, we are **not** using type annotations. + +Naming Conventions +-------------------- + +* ``variable_name`` and not ``variableName`` or ``VariableName``. Should be a + noun that describes what information is contained in the variable. If a ``bool``, + preface with ``is_`` or ``has_`` or similar question-word that can be answered + with a yes-or-no. +* ``function_name`` and not ``function_name`` or ``functionName``. Should be an + imperative that describes what the function does (e.g. ``get_next_page``). +* ``CONSTANT_NAME`` and not ``constant_name`` or ``ConstantName``. +* ``ClassName`` and not ``class_name`` or ``Class_Name``. + +Design Conventions +------------------- + +* Functions at the module level can only be aware of objects either at a higher + scope or singletons (which effectively have a higher scope). +* Functions and methods can use **one** positional argument (other than ``self`` + or ``cls``) without a default value. Any other arguments must be keyword + arguments with default value given. + + .. code-block:: python + + def do_some_function(argument): + # rest of function... + + def do_some_function(first_arg, + second_arg = None, + third_arg = True): + # rest of function ... + +* Functions and methods that accept values should start by validating their + input, throwing exceptions as appropriate. +* When defining a class, define all attributes in ``__init__``. +* When defining a class, start by defining its attributes and methods as private + using a single-underscore prefix. Then, only once they're implemented, decide + if they should be public. +* Don't be afraid of the private attribute/public property/public setter pattern: + + .. code-block:: python + + class SomeClass(object): + def __init__(*args, **kwargs): + self._private_attribute = None + + @property + def private_attribute(self): + # custom logic which may override the default return + + return self._private_attribute + + @setter.private_attribute + def private_attribute(self, value): + # custom logic that creates modified_value + + self._private_attribute = modified_value + +* Separate a function or method's final (or default) ``return`` from the rest of + the code with a blank line (except for single-line functions/methods). + +Documentation Conventions +---------------------------- + +We are very big believers in documentation (maybe you can tell). To document +the **Dynata Survey Authoring Python Library** we rely on several tools: + +`Sphinx`_ +^^^^^^^^^^^ + +`Sphinx`_ is used to organize the library's documentation into this lovely +readable format (which will also be published to `ReadTheDocs`_). This +documentation is written in `reStructuredText`_ files which are stored in +``/docs``. + +.. tip:: + As a general rule of thumb, we try to apply the `ReadTheDocs`_ own + `Documentation Style Guide`_ to our `RST `_ documentation. + +.. hint:: + + To build the HTML documentation locally: + + #. In a terminal, navigate to ``/docs``. + #. Execute ``make html``. + + When built locally, the HTML output of the documentation will be available at + ``./docs/_build/index.html``. + +Docstrings +^^^^^^^^^^^ +* Docstrings are used to document the actual source code itself. When + writing docstrings we adhere to the conventions outlined in :pep:`257`. + +.. _dependencies: + +Dependencies +============== + +.. include:: _dependencies.rst + +.. _preparing-development-environment: + +Preparing Your Development Environment +========================================= + +In order to prepare your local development environment, you should: + +#. Fork the `Git repository `_. +#. Clone your forked repository. +#. Set up a virtual environment (optional). +#. Install dependencies: + + .. code-block:: bash + + python-cmixapi-client/ $ pip install -r requirements.txt + +And you should be good to go! + +Ideas and Feature Requests +============================ + +Check for open `issues `_ +or create a new issue to start a discussion around a bug or feature idea. + +Testing +========= + +If you've added a new feature, we recommend you: + + * create local unit tests to verify that your feature works as expected, and + * run local unit tests before you submit the pull request to make sure nothing + else got broken by accident. + +.. seealso:: + + For more information about the **Dynata Survey Authoring Python Library** + testing approach please see: + :doc:`Testing the Dynata Survey Authoring Python Library ` + +Submitting Pull Requests +=========================== + +After you have made changes that you think are ready to be included in the main +library, submit a pull request on Github and one of our developers will review +your changes. + +A good PR completely resolves the associated issue, passes python linting, and +includes test coverage for your new code. This Github repository is integrated +with GitHub Actions, so a PR cannot be accepted that has merge conflicts, fails +to pass linting or tests, or lowers the repository's test coverage. Additionally +your PR should include a high level description of your work or reviewers will be +peppering you with questions. Approval of the maintainer is required merge a PR +into ``dev``, which is where all PRs go. + +Building Documentation +========================= + +In order to build documentation locally, you can do so from the command line using: + +.. code-block:: bash + + python-cmixapi-client/ $ cd docs + python-cmixapi-client/docs $ make html + +When the build process has finished, the HTML documentation will be locally +available at: + +.. code-block:: bash + + python-cmixapi-client/docs/_build/html/index.html + +.. note:: + + Built documentation (the HTML) is **not** included in the project's Git + repository. If you need local documentation, you'll need to build it. + +Contributors +================ + +Thanks to everyone who helps make the +**Dynata Survey Authoring (Cmix) Python Client** useful: + +.. include:: _contributors.rst + +References +============= + +.. target-notes:: + +.. _`Sphinx`: http://sphinx-doc.org +.. _`ReadTheDocs`: https://readthedocs.org +.. _`reStructuredText`: http://www.sphinx-doc.org/en/stable/rest.html +.. _`Documentation Style Guide`: http://documentation-style-guide-sphinx.readthedocs.io/en/latest/style-guide.html +.. _`talk given by Luciano Ramalho at PyCon 2016`: https://www.youtube.com/watch?v=k55d3ZUF3ZQ diff --git a/docs/errors.rst b/docs/errors.rst new file mode 100644 index 0000000..eadb0b9 --- /dev/null +++ b/docs/errors.rst @@ -0,0 +1,86 @@ +################################## +Error Reference +################################## + +.. module:: CmixAPIClient.error + +.. contents:: + :local: + :depth: 3 + :backlinks: entry + +---------- + +******************* +Handling Errors +******************* + +When functions within the library fail, they raise exceptions. There are three +ways for exceptions to provide you with information that is useful in different +circumstances: + +#. **Exception Type**. The type of the exception itself (and the name of that type) + tells you a lot about the nature of the error. On its own, this should be + enough for you to understand "what went wrong" and "why validation failed". + Most importantly, this is easy to catch in your code using ``try ... except`` + blocks, giving you fine-grained control over how to handle exceptional situations. +#. **Message**. Each exception is raised with a human-readable message, a brief + string that says "this is why this exception was raised". This is primarily + useful in debugging your code, because at run-time we don't want to parse + strings to make control flow decisions. +#. **Stack Trace**. Each exception is raised with a stacktrace of the exceptions + and calls that preceded it. This helps to provide the context for the error, and + is (typically) most useful for debugging and logging purposes. In rare circumstances, + we might want to programmatically parse this information...but that's a pretty + rare requirement. + +We have designed the exceptions raised by the **Dynata Survey Authoring** library +to leverage all three of these types of information. + +Error Names/Types +=========================== + +By design, all exceptions raised by the **Dynata Survey Authoring** library +inherit from the `built-in exceptions `_ +defined in the standard library. This makes it simple to plug the +**Dynata Survey Authoring** library into existing code which already catches +:class:`ValueError `, :class:`TypeError `, +and the like. + +However, because we have sub-classed the built-in exceptions, you can easily apply +more fine-grained control over your code. + +.. tip:: + + We **strongly** recommend that you review the exceptions raised by each of + the functions you use. Each function precisely documents which exceptions it + raises, and each exception's documentation shows what built-in exceptions it + inherits from. + +Error Messages +=========================== + +Because the **Dynata Survey Authoring** library produces exceptions which inherit +from the standard library, we leverage the same API. This means they print to +standard output with a human-readable message that provides an explanation for +"what went wrong." + +Stack Traces +=========================== + +Because the **Dynata Survey Authoring** library produces exceptions which inherit +from the standard library, it leverages the same API for handling stack trace +information. This means that it will be handled just like a normal exception in +unit test frameworks, logging solutions, and other tools that might need that +information. + +--------- + +******************* +Standard Errors +******************* + +CmixError (from :class:`Exception `) +========================================================== + +.. autoclass:: CmixError diff --git a/docs/glossary.rst b/docs/glossary.rst new file mode 100644 index 0000000..a63def4 --- /dev/null +++ b/docs/glossary.rst @@ -0,0 +1,128 @@ +################## +Glossary +################## + +.. glossary:: + + Banner Filter + TBD + + .. todo:: + + Populate the definition. + + Cmix + A legacy brand name for the modern **Dynata Survey Authoring** tool which is + still often used. + + Data Layout + A definition of the structure of :term:`variables ` to include in + a data file produced from collected or simulated survey data. + + Data Point + A set of information about a specific response to a particular survey question. + A data point includes information about the type of survey question, the + value of the response (i.e. "what the respondent said"), and the number/percentage + of respondents who answered with that response. + + Dynata Insights Platform + The unified end-to-end platform developed and offered by Dynata to provide + comprehensive access to audiences, survey authoring, and insights from + first-party data. + + Dynata Survey Authoring + The component of the :term:`Dynata Insights Platform` that is used to create + online surveys. This library is a Python client designed to provide + programmatic access to the Dynata Survey Authoring's capabilities. + + Group + TBD + + .. todo:: + + Populate the definition. + + Link + A URL that provides access to a resource or that is used to deliver + :term:`respondents ` to a :term:`survey `. + + Locale + A geographic area determined by both country and language code. + + Markup Files + TBD + + .. todo:: + + Populate the definition. + + Project + A single set of data collection that is to be performed at a given moment in + time. Each Project is composed of one or more :term:`surveys ` that + are used to collect data from :term:`respondents `. + + Respondent + An individual person who fills in a :term:`survey `, agreeing to + share their data for the purposes of statistical analysis. + + Respondent Links + TBD + + .. todo:: + + Populate the definition. + + Survey + A single questionnaire in a given language composed of multiple questions. + One :term:`Project` may have many surveys. + + Survey Definition + A human-and-machine-readable document that describes the survey content, + logic, and visual representation. + + Survey Section + TBD + + .. todo:: + + Populate the definition. + + Survey Simulation + A dataset produced for a given :term:`survey ` which was simulated + (i.e. produced at random, without collecting data from live + :term:`respondents `). + + Survey Source + TBD + + .. todo:: + + Populate the definition. + + + Termination Code + A data point that indicates when in a questionnaire and why a + :term:`respondent ` was disqualified from completing a + :term:`survey `. + + Test Link + A :term:`link ` that gives you access to a test-version of a given + :term:`survey `, allowing you to test the experience that a + :term:`respondent ` would have while filling in your survey. + + Test Token + TBD + + .. todo:: + + Populate the definition. + + Variable + A field of data in a data file that typically corresponds to either a single + question within a :term:`survey ` or a single response to such a + question. + + .. tip:: + + A good way of thinking about variables is to think of them as "columns" + in a data table. diff --git a/docs/history.rst b/docs/history.rst new file mode 100644 index 0000000..55154ad --- /dev/null +++ b/docs/history.rst @@ -0,0 +1,13 @@ +################## +Release History +################## + +.. sidebar:: Contributors + + .. include:: _contributors.rst + +.. contents:: + :depth: 3 + :backlinks: entry + +.. include:: ../CHANGES.rst diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..10a33a4 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,151 @@ +.. Dynata Survey Authoring (Cmix) Python Client documentation master file, created by + sphinx-quickstart on Fri Jul 10 18:27:27 2020. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +.. This documentation follows the Read The Docs documentation style guide which + is maintained here: + https://documentation-style-guide-sphinx.readthedocs.io/en/latest/style-guide.html + +################################################# +Dynata Survey Authoring (Cmix) Python Client +################################################# + +**Python library for programmatic interaction with the Dynata Survey Authoring +tool (Cmix)** + +.. sidebar:: Version Compatibility + + The **Dynata Survey Authoring** client is designed to be compatible with + Python 3.6 or higher. + +.. toctree:: + :hidden: + :maxdepth: 2 + :caption: Contents: + + Home + About Dynata Survey Authoring + Using the Client + The Survey Definition + Defining Survey Questions + Managing Survey Logic + API Reference + Error Reference + Contributor Guide + Testing Reference + Release History + Glossary + +The **Dynata Survey Authoring Python Client** is a Python library that provides +Python bindings for the Dynata Survey Authoring API. + +The library employs a standard and consistent syntax for easy use, and has been tested on +Python 3.6, 3.7, and 3.8. + +.. contents:: + :depth: 3 + :backlinks: entry + +*************** +Installation +*************** + +To install the **Dynata Survey Authoring Python Client**, just execute: + +.. code:: bash + + $ pip install python-cmixapi-client + +Dependencies +============== + +.. include:: _dependencies.rst + +************************************ +Hello, World and Standard Usage +************************************ + +Prerequisites +================ + +To use the **Dynata Survey Authoring Python Client** you first need to have +access to the Dynata Survey Authoring platform, and to have been granted API +credentials for programmatic use. If you need programmatic credentials, please +contact your Dynata account executive to discuss your needs. + +Authentication +================= + +For your Python application to interact with **Dynata Survey Authoring**, you +need to first authenticate against the platform: + +.. code-block:: python + + from CmixAPIClient.api import CmixAPI + + # 1. Initialize an instance of the DSA Python library with your authentication + # credentials. + cmix = CmixAPI( + username="test_username", + password="test_password", + client_id="test_client_id", + client_secret="test_client_secret" + ) + + # 2. Authenticate against the API. + cmix.authenticate() + + # 3. Execute whatever API calls you need to execute. + surveys = cmix.get_surveys('closed') + +Retrieving Surveys +====================== + +.. code-block:: python + + # Retrieve surveys whose status is 'closed'. + # Returns a JSON collection of survey objects as a Python dict + surveys = cmix.get_surveys('closed') + + +********************* +Questions and Issues +********************* + +You can ask questions and report issues on the project's +`Github Issues Page `_ + +********************* +Contributing +********************* + +We welcome contributions and pull requests! For more information, please see the +:doc:`Contributor Guide `. And thanks to all those who've already +contributed: + +.. include:: _contributors.rst + +********************* +Testing +********************* + +We use `TravisCI `_ for our build automation and +`ReadTheDocs `_ for our documentation. + +Detailed information about our test suite and how to run tests locally can be +found in our :doc:`Testing Reference `. + +********************** +License +********************** + +The **Dynata Survey Authoring Python Client** is made available on a **MIT License**. + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..922152e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/survey_definition/index.rst b/docs/survey_definition/index.rst new file mode 100644 index 0000000..2b1c084 --- /dev/null +++ b/docs/survey_definition/index.rst @@ -0,0 +1,467 @@ +.. This is based off of the documentation available at: + https://wiki2.criticalmix.net/display/CA/CMIX+Survey+Markup+Format+Specification + +############################# +The Survey Definition +############################# + +.. tip:: + + This documentation for the **Dynata Survey Definition** format is only partial. + We are working to expand it to include the full set of documentation, but in + the meantime we recommend you review the work-in-progress documentation + available here: + https://wiki2.criticalmix.net/display/CA/CMIX+Survey+Markup+Format+Specification + +.. sidebar:: XML or Not-XML? + + The Dynata Survey Definition is not -- strictly speaking -- an XML file. XML + is designed to be a strict and unforgiving language. HTML, which is what + surveys are ultimately rendered in, has been designed over the years to + naturally be more forgiving than the XML standard. + + By trying to maintain some level of consistency with the standards of + `HTML Custom Elements `_, + the Dynata Survey Definition format gains several advantages over native XML: + + * It is inherently more forgiving (e.g. can support unclosed tags). + * It enables the re-use of HTML tags to simplify browser-based-rendering. + * It allows for compatibility with standards-complaint technologies like + `Web Components `_ and + JavaScript-based component-based frameworks (e.g. + `Angular `_ or React). + +.. contents:: + :depth: 3 + :backlinks: entry + +-------------- + +********************************* +What is a Survey Definition? +********************************* + +The :term:`Survey Definition` is the "source code" for the survey you create +using the Dynata Survey Authoring tool (Cmix). It controls: + + * what questions your survey contains + * how those questions are arranged + * how the survey respondent navigates between those questions + * how those questions map to variables / data + +As such, your survey definition is literally the "heart" of your survey. It can +be defined and modified using a number of different tools: + + * **Dynata Survey Authoring (Cmix)**. One of the key advantages of the + Dynata Survey Authoring tool is that it allows you to use an easy-to-use + point-and-click system to create and modify your surveys. + * **Text Editor / IDE**. Because the survey definition can be exported out of the + tool itself and accessed via the Dynata Survey Authoring API, you can also + modify it using any text editor or IDE (Integrated Development Environment). + + .. caution:: + + This is a technique for advanced users. + + * **Programmatically**. Because the survey definition is a machine-readable + format, it is possible for your systems to automatically create or modify + survey definition files and deliver them to the tool using the Dynata Survey + Authoring API. + +------------------- + +*********************************************** +High-level Structure of a Survey Definition +*********************************************** + +At a high level, a :term:`Survey Definition` is composed of a number of "simple" +building blocks: + +* :ref:`Survey `. Unsurprisingly, this is the starting point for + the definition. Everything within the survey definition has to correspond to + the single definition of the survey itself. +* :ref:`Sections `. Each survey is composed of one or more + sections, where each section is a logical group of multiple + :ref:`pages `. +* :ref:`Logic Block ` (optional). Each section can include + one or more blocks of logic which perform a variety of evaluations and determine + how to direct the respondent based on those evaluations. +* :ref:`Pages `. Each section is composed of one or more pages, + where each page is a single "view" that is shown to a respondent when taking + the survey. This view is itself composed of :ref:`questions `. +* :ref:`Questions `. Each page is composed of one or more + questions, where each question is a survey question that is asked of a + respondent taking the survey. + +.. todo:: + + Add a diagram showing this structure. + +Besides these basic building blocks, there are a number of additional pieces +that are used to build more advanced structures and logic, including: + +* :ref:`Concepts ` which are used to define concept variables + which can be reused across the survey. +* :ref:`Lists ` which are used to define response lists which can + be reused across multiple survey questions. +* :ref:`Termination Codes ` which are used to define the + different termination codes which are used to demarcate a respondent as having + terminated a survey prior to completion. +* :ref:`Variables ` which are used to define variables that + can be populated through the survey, its logic, or populated by the + :term:`sample source `. + +-------------------------- + +************************ +Basic Building Blocks +************************ + +.. _survey_element: + +Survey Element +================= + +The ```` element represents a survey in its entirety. This element +should be the root of the survey definition (i.e. the ancestor of all other +elements). + + .. note:: + + * **MUST** be the first / highest-level element in the survey definition. + * One survey definition **CANNOT** have more than one ```` element. + + +Attributes +------------- + + .. py:attribute:: name + :type: string + :value: "My Survey" + :noindex: + + **REQUIRED**. Human-readable name given to the survey. + +Example +------------ + + .. code-block:: xml + + + ... + + +------------- + +.. _section_element: + +Section Element +=================== + +.. sidebar:: Parent Elements + + * :ref:`survey ` + +The ``
`` element defines a section, which is a collection of one or +more :ref:`pages ` or :ref:`logic blocks `. + + .. note:: + + * One survey definition **CAN** have multiple ``
`` elements. + + .. caution:: + + Do not confuse a ``
`` element with the HTML + `section tag `_ + which serves a different purpose. + +Attributes +------------------ + + .. py:attribute:: label + :type: string + :value: "My Section" + :noindex: + + Human-readable label given to the section. This label is not shown to + respondents, but will be visible within the Dynata Survey Authoring system. + + .. py:attribute:: loop + :type: string + :value: "concept_1" + :noindex: + + Machine-readable name of a :ref:`concept ` the section is + associated with in a :ref:`loops `. + +Example +--------------- + + .. tabs:: + + .. tab:: Standard + + .. code-block:: xml + + +
+ ... +
+ ... +
+ + .. tab:: With Loop + + .. code-block:: xml + + +
+ ... +
+ ... +
+ + .. seealso:: + + * :ref:`Survey Loops Explained ` + +------------- + +.. _logic_block_element: + +Logic Element +======================== + +.. sidebar:: Parent Elements + + * :ref:`section ` + * :ref:`survey ` + +The ```` element is used to define a set of logical evaluations and +decisions as to how to direct a respondent through the survey experience itself. + +It is composed of either: + + * :ref:`blocks ` and/or + * :ref:`loops ` + + .. seealso:: + + * :doc:`Managing Survey Logic ` + +Attributes +------------- + + .. py:attribute:: label + :type: string + :value: "My Logic Block" + :noindex: + + A human-readable label that is applied to the logic block. This label is not + shown to a respondent, but it will be the label shown in the Dynata Survey + Authoring tool for the logic block. + + .. py:attribute:: variable + :type: string + :value: "concept_1" + :noindex: + + If the logic block belongs within a loop, this is the loop concept that it + belongs. + +Example +------------- + + .. code-block:: xml + + +
+ + ... + + + ... + +
+
+ +------------ + +.. _block_element: + +Logic > Block Element +======================== + + .. todo:: + + Document this element. + +------------ + +.. _logic_loop_element: + +Logic > Loop Element +======================== + + .. todo:: + + Document this element. + +------------ + +.. _page_element: + +Page Element +======================== + +.. sidebar:: Parent Elements + + * :ref:`section ` + +The ```` element is used to define a visual page, a single rendered +view that is shown to a respondent, which may contain one or more +:ref:`questions `. + +Attributes +-------------- + + .. py:attribute:: label + :type: string + :value: "My Page" + :noindex: + + A human-readable label that is applied to the page. This label will **not** + be shown to a respondent, but will be shown in the Dynata Survey Authoring + tool to aid in navigating your survey structure. + +Example +------------- + + .. code-block:: xml + + +
+ + ... + + + ... + +
+
+ +------------- + +.. _question_element: + +Question Element +======================== + +.. sidebar:: Parent Elements + + * :ref:`page ` + +Attributes +------------- + + .. tip:: + + The attributes described below are the "standard" attributes that apply to + every question type. However, each question type may have additional attributes + that are used to configure its behavior and appearance in a more nuanced fashion. + + For more information, please see :doc:`Defining Survey Questions `. + + .. py:attribute:: name + :type: string + :value: "Q1" + :noindex: + + **REQUIRED**. The machine-readable name given to the question. + + .. py:attribute:: type + :type: string + :value: "radio" + :noindex: + + **REQUIRED**. Determines how the question should be rendered for a + respondent. Accepts one of the following acceptable values: + + * ``radio`` + * ``check-box`` + * ``coordinate-tracker`` + * ``dragdrop-bucket`` + * ``dragdrop-scale`` + * ``dropdown`` + * ``highlight-image`` + * ``highlight-text`` + * ``numeric`` + * ``passcode`` + * ``pii`` + * ``radio`` + * ``real-answer`` + * ``scale`` + * ``simple-grid`` + * ``slider`` + * ``none`` + * ``text`` + + .. seealso:: + + * :doc:`Defining Survey Questions ` + + .. py:attribute:: label + :type: string + :value: "Brand Awareness" + :noindex: + + A human-readable label that will be shown for the question when viewing + reports produced from the collected data. + + .. py:attribute:: required + :type: Boolean + :value: true + :noindex: + + If ``true``, forces the respondent to provide an answer to the question. + If ``false``, the respondent can proceed to the next question/step in the + survey without supplying an answer. + + .. py:attribute:: skip + :type: string + :value: TBD + :noindex: + + .. todo:: + + Document the formula syntax. + + Accepts a formula that is automatically evaluated when the respondent + reaches this question. If the formula evaluates to ``true``, then the + question will be skipped. + + .. py:attribute:: parameter + :type: string + :value: "param1" + :noindex: + + If present, will auto-populate this question with a value extracted from a + URL parameter with the name supplied. + + .. py:attribute:: display + :type: string + :value: TBD + :noindex: + + .. todo:: + + * Document the formula syntax. + * Document what this attribute does. + +Examples +------------ + + .. seealso:: + + For detailed documentation on how to construct questions, please see + :doc:`Defining Survey Questions `. diff --git a/docs/survey_definition/managing_logic.rst b/docs/survey_definition/managing_logic.rst new file mode 100644 index 0000000..aa0208e --- /dev/null +++ b/docs/survey_definition/managing_logic.rst @@ -0,0 +1,13 @@ +############################# +Managing Survey Logic +############################# + +.. contents:: + :depth: 3 + :backlinks: entry + +-------------- + +.. todo:: + + Write this content, including applicable recipes. diff --git a/docs/survey_definition/questions.rst b/docs/survey_definition/questions.rst new file mode 100644 index 0000000..af61b15 --- /dev/null +++ b/docs/survey_definition/questions.rst @@ -0,0 +1,13 @@ +############################# +Defining Survey Questions +############################# + +.. contents:: + :depth: 3 + :backlinks: entry + +-------------- + +.. todo:: + + Write this content, including applicable recipes. diff --git a/docs/testing.rst b/docs/testing.rst new file mode 100644 index 0000000..e3164b9 --- /dev/null +++ b/docs/testing.rst @@ -0,0 +1,9 @@ +############################################################### +Testing the Dynata Survey Authoring (Cmix) Python Library +############################################################### + +.. contents:: + :depth: 3 + :backlinks: entry + +.. automodule:: tests diff --git a/docs/using.rst b/docs/using.rst new file mode 100644 index 0000000..027faad --- /dev/null +++ b/docs/using.rst @@ -0,0 +1,13 @@ +############################# +Using the Client +############################# + +.. contents:: + :depth: 3 + :backlinks: entry + +-------------- + +.. todo:: + + Write this content, including applicable recipes. diff --git a/requirements.txt b/requirements.txt index bbad960..4ac49c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,7 @@ pytest==4.6.6 pytest-runner==5.2 requests==2.22.0 responses==0.10.6 +sphinx==1.8.5; python_version <= '2.7' +sphinx==3.1.2; python_version > '3.4' +sphinx-rtd-theme==0.5.0 +sphinx-tabs==1.1.13 diff --git a/setup.py b/setup.py index a1e3d26..37e8f31 100644 --- a/setup.py +++ b/setup.py @@ -1,31 +1,119 @@ -import setuptools +# Always prefer setuptools over distutils +from setuptools import setup, find_packages +# To use a consistent encoding +from codecs import open +from os import path -with open("README.md", "r") as fh: - long_description = fh.read() +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +# Get the version number from the VERSION file +version_dict = {} +with open(path.join(here, 'CmixAPIClient', '_version.py')) as version_file: + exec(version_file.read(), version_dict) # pylint: disable=W0122 + +version = version_dict.get('__version__') + +setup( + # This is the name of your project. The first time you publish this + # package, this name will be registered for you. It will determine how + # users can install this project, e.g.: + # + # $ pip install sampleproject + # + # And where it will live on PyPI: https://pypi.org/project/sampleproject/ + # + # There are some restrictions on what makes a valid project name + # specification here: + # https://packaging.python.org/specifications/core-metadata/#name + name="python-cmixapi-client", # Required + + # Versions should comply with PEP 440: + # https://www.python.org/dev/peps/pep-0440/ + # + # For a discussion on single-sourcing the version across setup.py and the + # project code, see + # https://packaging.python.org/en/latest/single_source_version.html + version=version, # Required -setuptools.setup( - name="python-cmixapi-client", - version="0.1.2", author="Bradley Wogsland", author_email="bradley@wogsland.org", - description="A Python client for the Cmix API.", + + description="A Python client for the Dynata Survey Authoring (Cmix) API.", long_description=long_description, long_description_content_type="text/markdown", + url="https://github.com/dynata/python-cmixapi-client", - packages=setuptools.find_packages(exclude=('tests', )), + + # You can just specify package directories manually here if your project is + # simple. Or you can use find_packages(). + # + # Alternatively, if you just want to distribute a single Python file, use + # the `py_modules` argument instead as follows, which will expect a file + # called `my_module.py` to exist: + # + # py_modules=["my_module"], + # + packages=find_packages(exclude=['contrib', 'docs', 'tests']), # Required + platforms=['Any'], + + # This field lists other packages that your project depends on to run. + # Any package you put here will be installed by pip when your project is + # installed, so they must be valid existing projects. + # + # For an analysis of "install_requires" vs pip's requirements files see: + # https://packaging.python.org/en/latest/requirements.html install_requires=['requests'], + + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4', + setup_requires=['pytest-runner'], tests_require=['pytest'], - keywords='cmix api dynata popresearch', + keywords='cmix api dynata popresearch survey', + classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 3 - Alpha', + + # Indicate who your project is intended for 'Intended Audience :: Developers', 'Operating System :: OS Independent', + 'Topic :: Software Development :: Libraries :: Python Modules', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + + # Pick your license as you wish + 'License :: OSI Approved :: MIT License', ], + + zip_safe=False, + + # List additional URLs that are relevant to your project as a dict. + # + # This field corresponds to the "Project-URL" metadata fields: + # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use + # + # Examples listed include a pattern for specifying where the package tracks + # issues, where the source is hosted, where to say thanks to the package + # maintainers, and where to support the project financially. The key is + # what's used to render the link text on PyPI. + project_urls={ # Optional + # 'Documentation': 'http://python-cmixapi-client.readthedocs.io/en/latest', + 'Bug Reports': 'https://github.com/dynata/python-cmixapi-client/issues', + 'Source': 'https://github.com/dynata/python-cmixapi-client/', + }, + ) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..b70ad60 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +""" +************************ +Testing Philosophy +************************ + +.. todo:: + + Re-write this whole page to align the testing philosophy with the implementation + of unit tests in the library. + +.. note:: + + Unit tests for the **Dynata Survey Authoring** library are written using + :mod:`unittest ` and managed/executed using `pytest`_. + +There are many schools of thought when it comes to test design. When building +the **Dynata Survey Authoring Python Library**, we decided to focus on +practicality. That means: + + * **DRY is good, KISS is better.** To avoid repetition, our test suite makes + extensive use of fixtures, parametrization, and decorator-driven behavior. + This minimizes the number of test functions that are nearly-identical. + However, there are certain elements of code that are repeated in almost all test + functions, as doing so will make future readability and maintenance of the + test suite easier. + * **Coverage matters...kind of.** We have documented the primary intended + behavior of every function in the **Dynata Survey Authoring** library, and the + most-likely failure modes that can be expected. At the time of writing, we + have about 85% code coverage. Yes, yes: We know that is less than 100%. But + there are edge cases which are almost impossible to bring about, based on + confluences of factors in the wide world. Our goal is to test the key + functionality, and as bugs are uncovered to add to the test functions as + necessary. + +************************ +Test Organization +************************ + +Each individual test module (e.g. ``test_modulename.py``) corresponds to a +conceptual grouping of functionality. For example: + +* ``test_api.py`` tests API functions found in + ``CmixAPIClient/api.py`` + +***************** +Linting +***************** + +Linting software is strongly recommended to improve code quality and maintain +readability in Python projects. Python's official linting package is called +:doc:`pycodestyle `, but another useful linting package is called +:doc:`flake8 `. + +Flake8 runs three different linters on your code, including +:doc:`pycodestyle ` and a package called +`PyFlakes `_ that checks for things like +unused imports. + +To lint the files: + +.. code-block:: bash + + python-cmixapi-client/ $ flake8 . + + +************************************** +Configuring & Running Tests +************************************** + +Configuration Files +====================== + +To support the automated execution of the library's test suite, we have prepared +a ``pytest.ini`` file that is used to establish environment variables for test +purposes. + +Default linting configuration is managed through both ``.flake8`` and +``pytcodestyle`` configuration files. + +Running Tests +============== + +Linting the Library +----------------------- + +.. code-block:: bash + + python-cmixapi-client/ $ flake8 . + + +Entire Test Suite +--------------------- + +.. code-block:: bash + + tests/ $ pytest + +Testing a Module +-------------------- + +.. code-block:: bash + + tests/ $ pytest tests/test_module.py + +Testing a Function +---------------------- + +.. code-block:: bash + + tests/ $ pytest tests/test_module.py -k 'test_my_test_function' + +.. target-notes:: + +.. _`pytest`: https://docs.pytest.org/en/latest/ +.. _`tox`: https://tox.readthedocs.io +.. _`mocks`: https://en.wikipedia.org/wiki/Mock_object +.. _`stubs`: https://en.wikipedia.org/wiki/Test_stub +"""