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")