digitalfabrik/integreat-cms

View on GitHub
integreat_cms/api/v3/chat/zammad_api.py

Summary

Maintainability
A
0 mins
Test Coverage
B
85%
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Callable
    from typing import Any

    from django.http import HttpResponse

from django.conf import settings
from requests.exceptions import HTTPError
from zammad_py import ZammadAPI

from ....cms.models import AttachmentMap, Region, UserChat

logger = logging.getLogger(__name__)


AUTO_ANSWER_STRING = "automatically generated message"


# pylint: disable=unused-argument
def _raise_or_return_json(self: Any, response: HttpResponse) -> dict:
    """
    Raise HTTPError before converting response to json

    :param response: Request response object
    """
    response.raise_for_status()

    try:
        json_value = response.json()
    except ValueError:
        return response.content
    return json_value


# pylint: disable=too-many-instance-attributes
class ZammadChatAPI:
    """
    Class providing an API for Zammad used in the context of user chats.

    :param url: The region's Zammad URL
    :param http_token: The region's client secret
    """

    def __init__(self, region: Region):
        self.client = ZammadAPI(
            url=f"{region.zammad_url}/api/v1/", http_token=region.zammad_access_token
        )

        # Patch the relevant methods to allow us to capture error response codes
        self.client.ticket.__class__.__base__._raise_or_return_json = (
            _raise_or_return_json
        )
        self.client.ticket_article.__class__.__base__._raise_or_return_json = (
            _raise_or_return_json
        )
        self.client.user.__class__.__base__._raise_or_return_json = (
            _raise_or_return_json
        )

        try:
            self.client_identity = self.client.user.me()["login"]
        except HTTPError:
            self.create_ticket = self.send_message = self.get_messages = (  # type: ignore[assignment]
                self.get_attachment  # type: ignore[method-assign]
            ) = lambda *_: {
                "status": 500,
                "error": "An error occurred while attempting to connect to the chat server.",
            }
            return

        self.ticket_group = settings.USER_CHAT_TICKET_GROUP
        self.responsible_handlers = region.zammad_chat_handlers

    @staticmethod
    def _attempt_call(call: Callable, *args: Any, **kwargs: Any) -> Any:
        try:
            return call(*args, **kwargs)
        except HTTPError as err:
            logger.warning(
                "A HTTP error with status %s occurred: %s",
                err.response.status_code,
                err.response.text,
            )
            return err.response.json() | {"status": err.response.status_code}

    def _parse_response(self, response: dict | list[dict]) -> dict | list[dict]:
        if isinstance(response, list):
            return [self._parse_response(item) for item in response]  # type: ignore[misc]

        if author := response.get("sender"):
            response["user_is_author"] = (
                author == "Customer" and response.get("subject") != AUTO_ANSWER_STRING
            )
        response["automatic_answer"] = response.get("subject") == AUTO_ANSWER_STRING
        keys_to_keep = [
            "status",
            "error",
            "id",
            "body",
            "user_is_author",
            "attachments",
            "automatic_answer",
        ]

        return {key: response[key] for key in keys_to_keep if key in response}

    # pylint: disable=method-hidden
    def create_ticket(self, device_id: str, language_slug: str) -> dict:
        """
        Create a new ticket (i.e. initialize a new chat conversation)

        :param device_id: ID of the user requesting a new chat
        :param language_slug: user's language
        """
        params = {
            "title": f"[Integreat Chat] [{language_slug.upper()}] {device_id}",
            "group": self.ticket_group,
            "customer": self.client_identity,
        }
        return self._parse_response(  # type: ignore[return-value]
            self._attempt_call(self.client.ticket.create, params=params)
        )

    @staticmethod
    def _transform_attachment(
        chat: UserChat, article_id: int, attachment: dict
    ) -> dict:
        return {
            "filename": attachment.get("filename", ""),
            "size": attachment.get("size", ""),
            "Content-Type": attachment.get("preferences", {}).get("Content-Type", ""),
            "id": AttachmentMap.objects.get_or_create(
                user_chat=chat,
                article_id=article_id,
                attachment_id=attachment["id"],
            )[0].random_hash,
        }

    # pylint: disable=method-hidden
    def get_messages(self, chat: UserChat) -> dict[str, dict | list[dict]]:
        """
        Get all non-internal messages for a given ticket

        :param chat: UserChat instance for the relevant Zammad ticket
        """
        raw_response = self._attempt_call(self.client.ticket.articles, chat.zammad_id)
        if not isinstance(raw_response, list):
            return self._parse_response(raw_response)  # type: ignore[return-value]

        response = self._parse_response(
            [article for article in raw_response if not article.get("internal")]
        )
        for message in response:
            if "attachments" in message:
                message["attachments"] = [
                    self._transform_attachment(chat, message["id"], attachment)
                    for attachment in message["attachments"]
                ]

        return {"messages": response}

    # pylint: disable=method-hidden
    def send_message(
        self, chat_id: int, message: str, internal: bool = False, auto: bool = False
    ) -> dict:
        """
        Post a new message to the given ticket
        """
        params = {
            "ticket_id": chat_id,
            "body": message,
            "type": "web",
            "content_type": "text/html",
            "internal": internal,
            "subject": (
                "automatically generated message" if auto else "app user message"
            ),
            "sender": "Customer" if not auto else "Agent",
        }
        return self._parse_response(  # type: ignore[return-value]
            self._attempt_call(self.client.ticket_article.create, params=params)
        )

    # pylint: disable=method-hidden
    def get_attachment(self, attachment_map: AttachmentMap) -> bytes | dict:
        """
        Get the (binary) attachment file from Zammad.

        :param attachment_map: the object containing the IDs Zammad requires to identify attachments
        :return: the binary object file or a dict containing an error message
        """
        return self._attempt_call(
            self.client.ticket_article_attachment.download,
            attachment_map.attachment_id,
            attachment_map.article_id,
            attachment_map.user_chat.zammad_id,
        )