Skip to content

Commit

Permalink
Merge branch 'master' into dev-new-delivery-service
Browse files Browse the repository at this point in the history
  • Loading branch information
ChrOertlin authored Sep 3, 2024
2 parents cd66e45 + f19de7d commit 9f9af6c
Show file tree
Hide file tree
Showing 18 changed files with 476 additions and 379 deletions.
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 62.2.4
current_version = 62.2.5
commit = True
tag = True
tag_name = v{new_version}
Expand Down
2 changes: 1 addition & 1 deletion cg/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__title__ = "cg"
__version__ = "62.2.4"
__version__ = "62.2.5"
59 changes: 27 additions & 32 deletions cg/clients/freshdesk/freshdesk_client.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import logging
from http import HTTPStatus
from pathlib import Path

from pydantic import ValidationError
from requests import RequestException, Response, Session
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3 import Retry

from cg.clients.freshdesk.constants import EndPoints
from cg.clients.freshdesk.exceptions import (
FreshdeskAPIException,
FreshdeskModelException,
)
from cg.clients.freshdesk.models import TicketCreate, TicketResponse

LOG = logging.getLogger(__name__)
from cg.clients.freshdesk.models import ReplyCreate, TicketCreate, TicketResponse
from cg.clients.freshdesk.utils import handle_client_errors, prepare_attachments


class FreshdeskClient:
Expand All @@ -24,33 +18,23 @@ def __init__(self, base_url: str, api_key: str):
self.api_key = api_key
self.session = self._get_session()

def create_ticket(self, ticket: TicketCreate) -> TicketResponse:
"""Create a ticket."""
LOG.debug(ticket.model_dump_json())
try:
response: Response = self.session.post(
url=self._url(EndPoints.TICKETS),
json=ticket.model_dump(exclude_none=True),
)
response.raise_for_status()
return TicketResponse.model_validate(response.json())
except RequestException as error:
LOG.error(f"Could not create ticket: {error}")
raise FreshdeskAPIException(error) from error
except ValidationError as error:
LOG.error(f"Response from Freshdesk does not fit model: {TicketResponse}.\n{error}")
raise FreshdeskModelException(error) from error
@handle_client_errors
def create_ticket(self, ticket: TicketCreate, attachments: list[Path] = None) -> TicketResponse:
"""Create a ticket with multipart form data."""
multipart_data = ticket.to_multipart_data()
files = prepare_attachments(attachments) if attachments else None

def _url(self, endpoint: str) -> str:
"""Get the full URL for the endpoint."""
return f"{self.base_url}{endpoint}"
response = self.session.post(
url=f"{self.base_url}{EndPoints.TICKETS}", data=multipart_data, files=files
)
response.raise_for_status()
return TicketResponse.model_validate(response.json())

@handle_client_errors
def _get_session(self) -> Session:
"""Configures and sets a session to be used for requests."""
session = Session()
self._configure_retries(session)
session.auth = (self.api_key, "X")
session.headers.update({"Content-Type": "application/json"})
self._configure_retries(session)
return session

@staticmethod
Expand All @@ -69,3 +53,14 @@ def _configure_retries(session: Session) -> None:
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("https://", adapter)

@handle_client_errors
def reply_to_ticket(self, reply: ReplyCreate, attachments: list[Path] = None) -> None:
"""Send a reply to an existing ticket in Freshdesk."""
url = f"{self.base_url}{EndPoints.TICKETS}/{reply.ticket_number}/reply"

files = prepare_attachments(attachments) if attachments else None
multipart_data = reply.to_multipart_data()

response = self.session.post(url=url, data=multipart_data, files=files)
response.raise_for_status()
53 changes: 39 additions & 14 deletions cg/clients/freshdesk/models.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,61 @@
from datetime import datetime
from typing import Tuple, Union

from pydantic import BaseModel
from pydantic import BaseModel, EmailStr, Field

from cg.clients.freshdesk.constants import Priority, Source, Status


class TicketCreate(BaseModel):
"""Freshdesk ticket."""

attachments: list[dict[str, str]] = []
email: str
attachments: list[str | bytes] = Field(default_factory=list)
email: EmailStr
email_config_id: int | None = None
description: str
name: str
priority: int = Priority.LOW
source: int = Source.EMAIL
status: int = Status.OPEN
subject: str
tags: list[str] = []
type: str | None = None
custom_fields: dict[str, str | int | float | None] = Field(default_factory=dict)

def to_multipart_data(self) -> list[Tuple[str, str | int | bytes]]:
"""Custom converter to multipart form data."""
multipart_data = []

for field, value in self.model_dump(exclude_none=True).items():
if isinstance(value, list):
multipart_data.extend([(f"{field}[]", v) for v in value])
elif isinstance(value, dict):
multipart_data.extend([(f"{field}[{k}]", v) for k, v in value.items()])
else:
multipart_data.append((field, value))

return multipart_data


class TicketResponse(BaseModel):
"""Freshdesk ticket response."""
"""Response from Freshdesk"""

attachments: list[dict[str, str]] = []
created_at: datetime | None = None
email: str
id: int
name: str | None = None
priority: int
source: int
status: int
description: str
subject: str
tags: list[str] = []
type: str | None = None
to_emails: list[str] | None = None
status: int
priority: int


class ReplyCreate(BaseModel):
"""Reply to a ticket."""

ticket_number: str
body: str

def to_multipart_data(self) -> list[Tuple[str, Union[str, int, bytes]]]:
"""Custom converter to multipart form data."""
multipart_data = [
("body", self.body),
]
return multipart_data
86 changes: 86 additions & 0 deletions cg/clients/freshdesk/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import logging
from functools import wraps
from pathlib import Path
from tempfile import TemporaryDirectory

from pydantic import ValidationError
from requests import ConnectionError, HTTPError
from requests.exceptions import MissingSchema

from cg.clients.freshdesk.exceptions import (
FreshdeskAPIException,
FreshdeskModelException,
)
from cg.constants.constants import FileFormat
from cg.io.controller import WriteFile

LOG = logging.getLogger(__name__)


def extract_error_detail(error):
"""Extract detailed error information from HTTPError."""
if error.response is not None:
try:
return error.response.json()
except ValueError:
return error.response.text
return None


def log_and_raise(exception_class, message, error):
"""Log the error and raise the appropriate exception."""
LOG.error(message)
raise exception_class(error) from error


def handle_client_errors(func):
"""Decorator to handle and log errors in Freshdesk client methods."""

@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except HTTPError as error:
error_detail = extract_error_detail(error)
log_and_raise(
FreshdeskAPIException,
f"Failed request to Freshdesk: {error} - Status code: "
f"{error.response.status_code if error.response else 'N/A'}, Details: {error_detail}",
error,
)
except (MissingSchema, ConnectionError) as error:
log_and_raise(FreshdeskAPIException, f"Request to Freshdesk failed: {error}", error)
except ValidationError as error:
log_and_raise(
FreshdeskModelException, f"Invalid response from Freshdesk: {error}", error
)
except ValueError as error:
log_and_raise(FreshdeskAPIException, f"Operation failed: {error}", error)
except Exception as error:
log_and_raise(
FreshdeskAPIException, f"Unexpected error in Freshdesk client: {error}", error
)

return wrapper


def prepare_attachments(attachments: list[Path]) -> list[tuple[str, tuple[str, bytes]]]:
"""Prepare the attachments for a request."""
return [
("attachments[]", (attachment.name, open(attachment, "rb"))) for attachment in attachments
]


def create_temp_attachment_file(content: dict, file_name: Path) -> TemporaryDirectory:
"""Create a file-based attachment."""
if content and file_name:
directory = TemporaryDirectory()
WriteFile.write_file_from_content(
content=content,
file_format=FileFormat.JSON,
file_path=Path(directory.name, "order.json"),
)
return directory
else:
LOG.error("Content or file path is None. Cannot create file attachment.")
raise ValueError("Both content and file path must be provided and cannot be None")
8 changes: 3 additions & 5 deletions cg/meta/orders/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import logging

from cg.apps.lims import LimsAPI
from cg.apps.osticket import OsTicket
from cg.meta.orders.ticket_handler import TicketHandler
from cg.models.orders.order import OrderIn, OrderType
from cg.services.orders.submitters.order_submitter_registry import (
Expand All @@ -28,13 +27,13 @@ def __init__(
self,
lims: LimsAPI,
status: Store,
osticket: OsTicket,
ticket_handler: TicketHandler,
submitter_registry: OrderSubmitterRegistry,
):
super().__init__()
self.lims = lims
self.status = status
self.ticket_handler: TicketHandler = TicketHandler(osticket_api=osticket, status_db=status)
self.ticket_handler = ticket_handler
self.submitter_registry = submitter_registry

def submit(self, project: OrderType, order_in: OrderIn, user_name: str, user_mail: str) -> dict:
Expand All @@ -45,7 +44,7 @@ def submit(self, project: OrderType, order_in: OrderIn, user_name: str, user_mai
submit_handler = self.submitter_registry.get_order_submitter(project)
submit_handler.order_validation_service.validate_order(order_in)
# detect manual ticket assignment
ticket_number: str | None = TicketHandler.parse_ticket_number(order_in.name)
ticket_number: str | None = self.ticket_handler.parse_ticket_number(order_in.name)
if not ticket_number:
ticket_number = self.ticket_handler.create_ticket(
order=order_in, user_name=user_name, user_mail=user_mail, project=project
Expand All @@ -54,7 +53,6 @@ def submit(self, project: OrderType, order_in: OrderIn, user_name: str, user_mai
self.ticket_handler.connect_to_ticket(
order=order_in,
user_name=user_name,
user_mail=user_mail,
project=project,
ticket_number=ticket_number,
)
Expand Down
Loading

0 comments on commit 9f9af6c

Please sign in to comment.