diff --git a/airflow/hooks/zendesk_hook.py b/airflow/hooks/zendesk_hook.py index ab8366e58f12a..38323c5880d74 100644 --- a/airflow/hooks/zendesk_hook.py +++ b/airflow/hooks/zendesk_hook.py @@ -19,7 +19,7 @@ import warnings -from airflow.providers.zendesk.hooks.zendesk import Zendesk, ZendeskError, ZendeskHook # noqa +from airflow.providers.zendesk.hooks.zendesk import ZendeskHook # noqa warnings.warn( "This module is deprecated. Please use `airflow.providers.zendesk.hooks.zendesk`.", diff --git a/airflow/providers/zendesk/CHANGELOG.rst b/airflow/providers/zendesk/CHANGELOG.rst index cf2dbc99ad865..549df0b02ada3 100644 --- a/airflow/providers/zendesk/CHANGELOG.rst +++ b/airflow/providers/zendesk/CHANGELOG.rst @@ -19,6 +19,19 @@ Changelog --------- +3.0.0 +..... + +Misc +~~~ +``ZendeskHook`` moved from using ``zdesk`` to ``zenpy`` package. + +Breaking changes +~~~~~~~~~~~~~~~~ +Changed the return type of ``ZendeskHook.get_conn`` to return a ``zenpy.Zenpy`` object instead of a ``zdesk.Zendesk`` object. +Deleted the ``ZendeskHook.call``, alternatively you can use the ``ZendeskHook.get`` method to make custom get calls to Zendesk API. +``Zendesk`` and ``ZendeskError`` classes are removed from ``airflow.hooks.zendesk_hook`` imports. + 2.0.1 ..... diff --git a/airflow/providers/zendesk/example_dags/__init__.py b/airflow/providers/zendesk/example_dags/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/airflow/providers/zendesk/example_dags/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/airflow/providers/zendesk/example_dags/example_zendesk_custom_get.py b/airflow/providers/zendesk/example_dags/example_zendesk_custom_get.py new file mode 100644 index 0000000000000..8332ba86d226b --- /dev/null +++ b/airflow/providers/zendesk/example_dags/example_zendesk_custom_get.py @@ -0,0 +1,43 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from datetime import datetime +from typing import Dict, List + +from airflow import DAG +from airflow.operators.python import PythonOperator +from airflow.providers.zendesk.hooks.zendesk import ZendeskHook + + +def zendesk_custom_get_request() -> List[Dict]: + hook = ZendeskHook() + response = hook.get( + url="https://yourdomain.zendesk.com/api/v2/organizations.json", + ) + return [org.to_dict() for org in response] + + +with DAG( + dag_id="zendesk_custom_get_dag", + schedule_interval=None, + start_date=datetime(2021, 1, 1), + catchup=False, +) as dag: + fetch_organizations = PythonOperator( + task_id="trigger_zendesk_hook", + python_callable=zendesk_custom_get_request, + ) diff --git a/airflow/providers/zendesk/hooks/zendesk.py b/airflow/providers/zendesk/hooks/zendesk.py index e139f50480625..2bff0ce34186d 100644 --- a/airflow/providers/zendesk/hooks/zendesk.py +++ b/airflow/providers/zendesk/hooks/zendesk.py @@ -15,11 +15,12 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import List, Optional, Tuple, Union -import time -from typing import Optional - -from zdesk import RateLimitError, Zendesk, ZendeskError +from zenpy import Zenpy +from zenpy.lib.api import BaseApi +from zenpy.lib.api_objects import JobStatus, Ticket, TicketAudit +from zenpy.lib.generator import SearchResultGenerator from airflow.hooks.base import BaseHook @@ -31,90 +32,89 @@ class ZendeskHook(BaseHook): :param zendesk_conn_id: The Airflow connection used for Zendesk credentials. """ - def __init__(self, zendesk_conn_id: str) -> None: + conn_name_attr = 'zendesk_conn_id' + default_conn_name = 'zendesk_default' + conn_type = 'zendesk' + hook_name = 'Zendesk' + + def __init__(self, zendesk_conn_id: str = default_conn_name) -> None: super().__init__() - self.__zendesk_conn_id = zendesk_conn_id - self.__url = None + self.zendesk_conn_id = zendesk_conn_id + self.base_api: Optional[BaseApi] = None + zenpy_client, url = self._init_conn() + self.zenpy_client = zenpy_client + self.__url = url + self.get = self.zenpy_client.users._get + + def _init_conn(self) -> Tuple[Zenpy, str]: + """ + Create the Zenpy Client for our Zendesk connection. + + :return: zenpy.Zenpy client and the url for the API. + """ + conn = self.get_connection(self.zendesk_conn_id) + url = "https://" + conn.host + domain = conn.host + subdomain: Optional[str] = None + if conn.host.count(".") >= 2: + dot_splitted_string = conn.host.rsplit(".", 2) + subdomain = dot_splitted_string[0] + domain = ".".join(dot_splitted_string[1:]) + return Zenpy(domain=domain, subdomain=subdomain, email=conn.login, password=conn.password), url + + def get_conn(self) -> Zenpy: + """ + Get the underlying Zenpy client. + + :return: zenpy.Zenpy client. + """ + return self.zenpy_client + + def get_ticket(self, ticket_id: int) -> Ticket: + """ + Retrieve ticket. - def get_conn(self) -> Zendesk: - conn = self.get_connection(self.__zendesk_conn_id) - self.__url = "https://" + conn.host - return Zendesk( - zdesk_url=self.__url, zdesk_email=conn.login, zdesk_password=conn.password, zdesk_token=True - ) + :return: Ticket object retrieved. + """ + return self.zenpy_client.tickets(id=ticket_id) + + def search_tickets(self, **kwargs) -> SearchResultGenerator: + """ + Search tickets. + + :param kwargs: (optional) Search fields given to the zenpy search method. + :return: SearchResultGenerator of Ticket objects. + """ + return self.zenpy_client.search(type='ticket', **kwargs) + + def create_tickets(self, tickets: Union[Ticket, List[Ticket]], **kwargs) -> Union[TicketAudit, JobStatus]: + """ + Create tickets. + + :param tickets: Ticket or List of Ticket to create. + :param kwargs: (optional) Additional fields given to the zenpy create method. + :return: A TicketAudit object containing information about the Ticket created. + When sending bulk request, returns a JobStatus object. + """ + return self.zenpy_client.tickets.create(tickets, **kwargs) - def __handle_rate_limit_exception(self, rate_limit_exception: ZendeskError) -> None: + def update_tickets(self, tickets: Union[Ticket, List[Ticket]], **kwargs) -> Union[TicketAudit, JobStatus]: """ - Sleep for the time specified in the exception. If not specified, wait - for 60 seconds. + Update tickets. + + :param tickets: Updated Ticket or List of Ticket object to update. + :param kwargs: (optional) Additional fields given to the zenpy update method. + :return: A TicketAudit object containing information about the Ticket updated. + When sending bulk request, returns a JobStatus object. """ - retry_after = int(rate_limit_exception.response.headers.get('Retry-After', 60)) - self.log.info("Hit Zendesk API rate limit. Pausing for %s seconds", retry_after) - time.sleep(retry_after) - - def call( - self, - path: str, - query: Optional[dict] = None, - get_all_pages: bool = True, - side_loading: bool = False, - ) -> dict: + return self.zenpy_client.tickets.update(tickets, **kwargs) + + def delete_tickets(self, tickets: Union[Ticket, List[Ticket]], **kwargs) -> None: """ - Call Zendesk API and return results - - :param path: The Zendesk API to call - :param query: Query parameters - :param get_all_pages: Accumulate results over all pages before - returning. Due to strict rate limiting, this can often timeout. - Waits for recommended period between tries after a timeout. - :param side_loading: Retrieve related records as part of a single - request. In order to enable side-loading, add an 'include' - query parameter containing a comma-separated list of resources - to load. For more information on side-loading see - https://developer.zendesk.com/rest_api/docs/core/side_loading + Delete tickets, returns nothing on success and raises APIException on failure. + + :param tickets: Ticket or List of Ticket to delete. + :param kwargs: (optional) Additional fields given to the zenpy delete method. + :return: """ - query_params = query or {} - zendesk = self.get_conn() - first_request_successful = False - - while not first_request_successful: - try: - results = zendesk.call(path, query_params) - first_request_successful = True - except RateLimitError as rle: - self.__handle_rate_limit_exception(rle) - - # Find the key with the results - keys = [path.split("/")[-1].split(".json")[0]] - next_page = results['next_page'] - if side_loading: - keys += query_params['include'].split(',') - results = {key: results[key] for key in keys} - - if get_all_pages: - while next_page is not None: - try: - # Need to split because the next page URL has - # `github.zendesk...` - # in it, but the call function needs it removed. - next_url = next_page.split(self.__url)[1] - self.log.info("Calling %s", next_url) - more_res = zendesk.call(next_url) - for key in results: - results[key].extend(more_res[key]) - if next_page == more_res['next_page']: - # Unfortunately zdesk doesn't always throw ZendeskError - # when we are done getting all the data. Sometimes the - # next just refers to the current set of results. - # Hence, need to deal with this special case - break - next_page = more_res['next_page'] - except RateLimitError as rle: - self.__handle_rate_limit_exception(rle) - except ZendeskError as zde: - if b"Use a start_time older than 5 minutes" in zde.msg: - # We have pretty up to date data - break - raise zde - - return results + return self.zenpy_client.tickets.delete(tickets, **kwargs) diff --git a/airflow/providers/zendesk/provider.yaml b/airflow/providers/zendesk/provider.yaml index 9c12fa15014eb..4f725727491e1 100644 --- a/airflow/providers/zendesk/provider.yaml +++ b/airflow/providers/zendesk/provider.yaml @@ -22,6 +22,7 @@ description: | `Zendesk `__ versions: + - 3.0.0 - 2.0.1 - 2.0.0 - 1.0.1 diff --git a/docs/apache-airflow-providers-zendesk/index.rst b/docs/apache-airflow-providers-zendesk/index.rst index 5d90d31c17dee..c77ded02ab26b 100644 --- a/docs/apache-airflow-providers-zendesk/index.rst +++ b/docs/apache-airflow-providers-zendesk/index.rst @@ -27,6 +27,7 @@ Content :caption: References Python API <_api/airflow/providers/zendesk/index> + Example DAGs PyPI Repository Installing from sources diff --git a/docs/conf.py b/docs/conf.py index 93ce979f5e809..e45060379ec32 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -583,7 +583,7 @@ def _get_params(root_schema: dict, prefix: str = "", default_section: str = "") 'tenacity', 'vertica_python', 'winrm', - 'zdesk', + 'zenpy', ] # The default options for autodoc directives. They are applied to all autodoc directives automatically. diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 0dc819c9ad8ed..2a9ca72e5098f 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -34,6 +34,7 @@ BackfillJobTest Backfills Banco BaseClient +BaseObject BaseOperator BaseView BaseXCom @@ -202,6 +203,7 @@ Jira JobComplete JobExists JobRunning +JobStatus JobTrigger Json Jupyter @@ -328,6 +330,7 @@ SSHTunnelForwarder SaaS Sagemaker Sasl +SearchResultGenerator SecretManagerClient Seedlist Sendgrid @@ -382,6 +385,7 @@ Terraform TextToSpeechClient Tez Thinknear +TicketAudit ToC Tooltip Tunables @@ -420,6 +424,7 @@ Yandex Yieldr Zego Zendesk +Zenpy Zsh Zymergen abc @@ -1502,5 +1507,6 @@ youtrack youtube zA zendesk +zenpy zope zsh diff --git a/setup.py b/setup.py index b55ca64e24fd7..8d9a9bb6c29f4 100644 --- a/setup.py +++ b/setup.py @@ -525,7 +525,7 @@ def write_version(filename: str = os.path.join(*[my_dir, "airflow", "git_version 'yandexcloud>=0.122.0', ] zendesk = [ - 'zdesk', + 'zenpy>=2.0.24', ] # End dependencies group diff --git a/tests/providers/zendesk/hooks/test_zendesk.py b/tests/providers/zendesk/hooks/test_zendesk.py index ba3c273b34b1b..aa2e5d90764f3 100644 --- a/tests/providers/zendesk/hooks/test_zendesk.py +++ b/tests/providers/zendesk/hooks/test_zendesk.py @@ -16,103 +16,70 @@ # specific language governing permissions and limitations # under the License. # - -import unittest -from unittest import mock +from unittest.mock import patch import pytest -from zdesk import RateLimitError +from zenpy.lib.api_objects import Ticket +from airflow.models import Connection from airflow.providers.zendesk.hooks.zendesk import ZendeskHook +from airflow.utils import db -class TestZendeskHook(unittest.TestCase): - @mock.patch("airflow.providers.zendesk.hooks.zendesk.time") - def test_sleeps_for_correct_interval(self, mocked_time): - sleep_time = 10 - # To break out of the otherwise infinite tries - mocked_time.sleep = mock.Mock(side_effect=ValueError, return_value=3) - conn_mock = mock.Mock() - mock_response = mock.Mock() - mock_response.headers.get.return_value = sleep_time - conn_mock.call = mock.Mock( - side_effect=RateLimitError(msg="some message", code="some code", response=mock_response) - ) - - zendesk_hook = ZendeskHook("conn_id") - zendesk_hook.get_conn = mock.Mock(return_value=conn_mock) - - with pytest.raises(ValueError): - zendesk_hook.call("some_path", get_all_pages=False) - mocked_time.sleep.assert_called_once_with(sleep_time) - - @mock.patch("airflow.providers.zendesk.hooks.zendesk.Zendesk") - def test_returns_single_page_if_get_all_pages_false(self, _): - zendesk_hook = ZendeskHook("conn_id") - mock_connection = mock.Mock() - mock_connection.host = "some_host" - zendesk_hook.get_connection = mock.Mock(return_value=mock_connection) - zendesk_hook.get_conn() +class TestZendeskHook: + conn_id = 'zendesk_conn_id_test' - mock_conn = mock.Mock() - mock_call = mock.Mock(return_value={'next_page': 'https://some_host/something', 'path': []}) - mock_conn.call = mock_call - zendesk_hook.get_conn = mock.Mock(return_value=mock_conn) - zendesk_hook.call("path", get_all_pages=False) - mock_call.assert_called_once_with("path", {}) + @pytest.fixture(autouse=True) + def init_connection(self): + db.merge_conn( + Connection( + conn_id=self.conn_id, + conn_type='zendesk', + host='yoursubdomain.zendesk.com', + login='user@gmail.com', + password='eb243592-faa2-4ba2-a551q-1afdf565c889', + ) + ) + self.hook = ZendeskHook(zendesk_conn_id=self.conn_id) - @mock.patch("airflow.providers.zendesk.hooks.zendesk.Zendesk") - def test_returns_multiple_pages_if_get_all_pages_true(self, _): - zendesk_hook = ZendeskHook("conn_id") - mock_connection = mock.Mock() - mock_connection.host = "some_host" - zendesk_hook.get_connection = mock.Mock(return_value=mock_connection) - zendesk_hook.get_conn() + def test_hook_init_and_get_conn(self): + # Verify config of zenpy APIs + zenpy_client = self.hook.get_conn() + assert zenpy_client.users.subdomain == 'yoursubdomain' + assert zenpy_client.users.domain == 'zendesk.com' + assert zenpy_client.users.session.auth == ('user@gmail.com', 'eb243592-faa2-4ba2-a551q-1afdf565c889') + assert not zenpy_client.cache.disabled + assert self.hook._ZendeskHook__url == 'https://yoursubdomain.zendesk.com' - mock_conn = mock.Mock() - mock_call = mock.Mock(return_value={'next_page': 'https://some_host/something', 'path': []}) - mock_conn.call = mock_call - zendesk_hook.get_conn = mock.Mock(return_value=mock_conn) - zendesk_hook.call("path", get_all_pages=True) - assert mock_call.call_count == 2 + def test_get_ticket(self): + zenpy_client = self.hook.get_conn() + with patch.object(zenpy_client, 'tickets') as tickets_mock: + self.hook.get_ticket(ticket_id=1) + tickets_mock.assert_called_once_with(id=1) - @mock.patch("airflow.providers.zendesk.hooks.zendesk.Zendesk") - def test_zdesk_is_inited_correctly(self, mock_zendesk): - conn_mock = mock.Mock() - conn_mock.host = "conn_host" - conn_mock.login = "conn_login" - conn_mock.password = "conn_pass" + def test_search_tickets(self): + zenpy_client = self.hook.get_conn() + with patch.object(zenpy_client, 'search') as search_mock: + self.hook.search_tickets(status='open', sort_order='desc') + search_mock.assert_called_once_with(type='ticket', status='open', sort_order='desc') - zendesk_hook = ZendeskHook("conn_id") - zendesk_hook.get_connection = mock.Mock(return_value=conn_mock) - zendesk_hook.get_conn() - mock_zendesk.assert_called_once_with( - zdesk_url='https://conn_host', - zdesk_email='conn_login', - zdesk_password='conn_pass', - zdesk_token=True, - ) + def test_create_tickets(self): + zenpy_client = self.hook.get_conn() + ticket = Ticket(subject="This is a test ticket to create") + with patch.object(zenpy_client.tickets, 'create') as search_mock: + self.hook.create_tickets(ticket, extra_parameter="extra_parameter") + search_mock.assert_called_once_with(ticket, extra_parameter="extra_parameter") - @mock.patch("airflow.providers.zendesk.hooks.zendesk.Zendesk") - def test_zdesk_sideloading_works_correctly(self, mock_zendesk): - zendesk_hook = ZendeskHook("conn_id") - mock_connection = mock.Mock() - mock_connection.host = "some_host" - zendesk_hook.get_connection = mock.Mock(return_value=mock_connection) - zendesk_hook.get_conn() + def test_update_tickets(self): + zenpy_client = self.hook.get_conn() + ticket = Ticket(subject="This is a test ticket to update") + with patch.object(zenpy_client.tickets, 'update') as search_mock: + self.hook.update_tickets(ticket, extra_parameter="extra_parameter") + search_mock.assert_called_once_with(ticket, extra_parameter="extra_parameter") - mock_conn = mock.Mock() - mock_call = mock.Mock( - return_value={ - 'next_page': 'https://some_host/something', - 'tickets': [], - 'users': [], - 'groups': [], - } - ) - mock_conn.call = mock_call - zendesk_hook.get_conn = mock.Mock(return_value=mock_conn) - results = zendesk_hook.call( - ".../tickets.json", query={"include": "users,groups"}, get_all_pages=False, side_loading=True - ) - assert results == {'groups': [], 'users': [], 'tickets': []} + def test_delete_tickets(self): + zenpy_client = self.hook.get_conn() + ticket = Ticket(subject="This is a test ticket to delete") + with patch.object(zenpy_client.tickets, 'delete') as search_mock: + self.hook.delete_tickets(ticket, extra_parameter="extra_parameter") + search_mock.assert_called_once_with(ticket, extra_parameter="extra_parameter")