From fc11fd53797c25ac091454b9a700f3bb2099eb5b Mon Sep 17 00:00:00 2001 From: Ahmed Amin <82151354+ahdamin@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:04:36 +0200 Subject: [PATCH 1/2] Feat(ticketing) Use Freshdesk (#3668) ## Description This PR switches the client used by the tickethandler from Osticket to freshdesk ### Changed - replace `osticket` with `freshdesk` --- cg/clients/freshdesk/freshdesk_client.py | 59 ++-- cg/clients/freshdesk/models.py | 53 +++- cg/clients/freshdesk/utils.py | 86 ++++++ cg/meta/orders/api.py | 8 +- cg/meta/orders/ticket_handler.py | 92 +++--- cg/server/app.py | 2 - cg/server/app_config.py | 32 +- cg/server/endpoints/orders.py | 23 +- cg/server/ext.py | 20 +- tests/apps/test_osticket.py | 31 -- tests/conftest.py | 11 +- tests/meta/orders/conftest.py | 17 +- tests/meta/orders/test_meta_orders_api.py | 358 +++++++++++++--------- tests/mocks/osticket.py | 54 ---- tests/server/conftest.py | 3 - 15 files changed, 473 insertions(+), 376 deletions(-) create mode 100644 cg/clients/freshdesk/utils.py delete mode 100644 tests/apps/test_osticket.py delete mode 100644 tests/mocks/osticket.py diff --git a/cg/clients/freshdesk/freshdesk_client.py b/cg/clients/freshdesk/freshdesk_client.py index 535ef06821..9a9650f9b1 100644 --- a/cg/clients/freshdesk/freshdesk_client.py +++ b/cg/clients/freshdesk/freshdesk_client.py @@ -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: @@ -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 @@ -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() diff --git a/cg/clients/freshdesk/models.py b/cg/clients/freshdesk/models.py index 7a0cae7f43..aa0be87ab4 100644 --- a/cg/clients/freshdesk/models.py +++ b/cg/clients/freshdesk/models.py @@ -1,6 +1,6 @@ -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 @@ -8,9 +8,10 @@ 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 @@ -18,19 +19,43 @@ class TicketCreate(BaseModel): 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 diff --git a/cg/clients/freshdesk/utils.py b/cg/clients/freshdesk/utils.py new file mode 100644 index 0000000000..2e78b23cf9 --- /dev/null +++ b/cg/clients/freshdesk/utils.py @@ -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") diff --git a/cg/meta/orders/api.py b/cg/meta/orders/api.py index 0222d4f971..228a9570e0 100644 --- a/cg/meta/orders/api.py +++ b/cg/meta/orders/api.py @@ -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 ( @@ -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: @@ -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 @@ -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, ) diff --git a/cg/meta/orders/ticket_handler.py b/cg/meta/orders/ticket_handler.py index ba6074068f..91d806fdfa 100644 --- a/cg/meta/orders/ticket_handler.py +++ b/cg/meta/orders/ticket_handler.py @@ -4,9 +4,8 @@ from tempfile import TemporaryDirectory from typing import Any -from sendmail_container import FormDataRequest - -from cg.apps.osticket import OsTicket +from cg.clients.freshdesk.freshdesk_client import FreshdeskClient +from cg.clients.freshdesk.models import ReplyCreate, TicketCreate, TicketResponse from cg.models.orders.order import OrderIn from cg.models.orders.samples import Of1508Sample from cg.store.models import Customer, Sample @@ -20,15 +19,17 @@ class TicketHandler: NEW_LINE = "
" - def __init__(self, osticket_api: OsTicket, status_db: Store): - self.osticket: OsTicket = osticket_api - self.status_db: Store = status_db + def __init__(self, db: Store, client: FreshdeskClient, system_email_id: int, env: str): + self.client: FreshdeskClient = client + self.status_db: Store = db + self.system_email_id: int = system_email_id + self.env: str = env @staticmethod def parse_ticket_number(name: str) -> str | None: """Try to parse a ticket number from a string""" # detect manual ticket assignment - ticket_match = re.fullmatch(r"#([0-9]{6})", name) + ticket_match = re.fullmatch(r"#(\d{6,10})", name) if ticket_match: ticket_id = ticket_match.group(1) LOG.info(f"{ticket_id}: detected ticket in order name") @@ -45,22 +46,36 @@ def create_ticket( order=order, project=project, ) - attachment: dict = self.create_attachment(order=order) - ticket_nr: str | None = self.osticket.open_ticket( - name=user_name, - email=user_mail, - subject=order.name, - message=message, - attachment=attachment, - ) - LOG.info(f"{ticket_nr}: opened new ticket") - return ticket_nr + with TemporaryDirectory() as temp_dir: + attachments: Path = self.create_attachment_file(order=order, temp_dir=temp_dir) + + freshdesk_ticket = TicketCreate( + email=user_mail, + description=message, + email_config_id=self.system_email_id, + name=user_name, + subject=order.name, + type="Order", + tags=[order.samples[0].data_analysis], + custom_fields={ + "cf_environment": self.env, + }, + attachments=[], + ) + ticket_response: TicketResponse = self.client.create_ticket( + ticket=freshdesk_ticket, attachments=[attachments] + ) + LOG.info(f"{ticket_response.id}: opened new ticket in Freshdesk") - def create_attachment(self, order: OrderIn): - return self.osticket.create_new_ticket_attachment( - content=self.replace_empty_string_with_none(obj=order.dict()), file_name="order.json" - ) + return ticket_response.id + + def create_attachment_file(self, order: OrderIn, temp_dir: str) -> Path: + """Create a single attachment file for the ticket""" + order_file_path = Path(temp_dir) / "order.json" + with order_file_path.open("w") as order_file: + order_file.write(order.json()) + return order_file_path def create_xml_sample_list(self, order: OrderIn, user_name: str) -> str: message = "" @@ -88,10 +103,7 @@ def create_xml_sample_list(self, order: OrderIn, user_name: str) -> str: @staticmethod def create_new_ticket_header(message: str, order: OrderIn, project: str) -> str: - return ( - f"data:text/html;charset=utf-8, New order with {len(order.samples)} {project} samples:" - + message - ) + return f"New order with {len(order.samples)} {project} samples:" + message @staticmethod def add_existing_ticket_header(message: str, order: OrderIn, project: str) -> str: @@ -178,27 +190,25 @@ def replace_empty_string_with_none(cls, obj: Any) -> Any: return obj def connect_to_ticket( - self, order: OrderIn, user_name: str, user_mail: str, project: str, ticket_number: str + self, order: OrderIn, user_name: str, project: str, ticket_number: str ) -> None: """Appends a new order message to the ticket selected by the customer""" LOG.info(f"Connecting order to ticket {ticket_number}") + message: str = self.add_existing_ticket_header( message=self.create_xml_sample_list(order=order, user_name=user_name), order=order, project=project, ) - sender_prefix, email_server_alias = user_mail.split("@") - temp_dir: TemporaryDirectory = self.osticket.create_connecting_ticket_attachment( - content=self.replace_empty_string_with_none(obj=order.dict()) - ) - email_form = FormDataRequest( - sender_prefix=sender_prefix, - email_server_alias=email_server_alias, - request_uri=self.osticket.email_uri, - recipients=self.osticket.osticket_email, - mail_title=f"[#{ticket_number}]", - mail_body=message, - attachments=[Path(f"{temp_dir.name}/order.json")], - ) - email_form.submit() - temp_dir.cleanup() + + with TemporaryDirectory() as temp_dir: + attachments: Path = self.create_attachment_file(order=order, temp_dir=temp_dir) + + reply = ReplyCreate(ticket_number=ticket_number, body=message) + + self.client.reply_to_ticket( + reply=reply, + attachments=[attachments], + ) + + LOG.info(f"Connected order to ticket {ticket_number} in Freshdesk") diff --git a/cg/server/app.py b/cg/server/app.py index c9f3cd477c..4df92b1db4 100644 --- a/cg/server/app.py +++ b/cg/server/app.py @@ -66,8 +66,6 @@ def _configure_extensions(app: Flask): ext.db.init_app(app) ext.lims.init_app(app) ext.analysis_client.init_app(app) - if app.config["osticket_api_key"]: - ext.osticket.init_app(app) ext.admin.init_app(app, index_view=AdminIndexView(endpoint="admin")) app.json_provider_class = ext.CustomJSONEncoder diff --git a/cg/server/app_config.py b/cg/server/app_config.py index 34e8fe96ba..97f38b3235 100644 --- a/cg/server/app_config.py +++ b/cg/server/app_config.py @@ -2,25 +2,29 @@ class AppConfig(BaseSettings): + """Default values are overridden by environment variable at run-time.""" + gunicorn_workers: int = 4 gunicorn_threads: int = 4 - gunicorn_bind: str + gunicorn_bind: str = "0.0.0.0:8000" gunicorn_timeout: int = 400 - cg_sql_database_uri: str + cg_sql_database_uri: str = "sqlite:///" cg_secret_key: str = "thisIsNotASafeKey" invoice_max_price: int = 750_000 - lims_host: str - lims_username: str - lims_password: str - osticket_api_key: str - osticket_domain: str - support_system_email: str - email_uri: str - google_oauth_client_id: str - google_oauth_client_secret: str - trailblazer_host: str - trailblazer_service_account: str - trailblazer_service_account_auth_file: str + lims_host: str = "lims_host" + lims_username: str = "username" + lims_password: str = "password" + support_system_email: str = "support@mail.com" + email_uri: str = "smtp://localhost" + google_oauth_client_id: str = "client_id" + google_oauth_client_secret: str = "client_secret" + trailblazer_host: str = "trailblazer_host" + trailblazer_service_account: str = "service_account" + trailblazer_service_account_auth_file: str = "auth_file.json" + freshdesk_url: str = "https://company.freshdesk.com" + freshdesk_api_key: str = "freshdesk_api_key" + freshdesk_order_email_id: int = 10 + freshdesk_environment: str = "Stage" app_config = AppConfig() diff --git a/cg/server/endpoints/orders.py b/cg/server/endpoints/orders.py index 8261233f47..d2bd64bb15 100644 --- a/cg/server/endpoints/orders.py +++ b/cg/server/endpoints/orders.py @@ -28,8 +28,12 @@ from cg.meta.orders.ticket_handler import TicketHandler from cg.models.orders.order import OrderIn, OrderType from cg.models.orders.orderform_schema import Orderform -from cg.server.dto.delivery_message.delivery_message_response import DeliveryMessageResponse -from cg.server.dto.orders.order_delivery_update_request import OrderDeliveredUpdateRequest +from cg.server.dto.delivery_message.delivery_message_response import ( + DeliveryMessageResponse, +) +from cg.server.dto.orders.order_delivery_update_request import ( + OrderDeliveredUpdateRequest, +) from cg.server.dto.orders.order_patch_request import OrderDeliveredPatch from cg.server.dto.orders.orders_request import OrdersRequest from cg.server.dto.orders.orders_response import Order, OrdersResponse @@ -39,14 +43,10 @@ delivery_message_service, lims, order_service, - osticket, order_submitter_registry, + ticket_handler, ) -from cg.store.models import ( - Application, - Customer, -) - +from cg.store.models import Application, Customer ORDERS_BLUEPRINT = Blueprint("orders", __name__, url_prefix="/api/v1") ORDERS_BLUEPRINT.before_request(before_request) @@ -157,7 +157,10 @@ def create_order_from_form(): def submit_order(order_type): """Submit an order for samples.""" api = OrdersAPI( - lims=lims, status=db, osticket=osticket, submitter_registry=order_submitter_registry + lims=lims, + status=db, + ticket_handler=ticket_handler, + submitter_registry=order_submitter_registry, ) error_message: str try: @@ -170,7 +173,7 @@ def submit_order(order_type): ) project = OrderType(order_type) order_in = OrderIn.parse_obj(request_json, project=project) - existing_ticket: str | None = TicketHandler.parse_ticket_number(order_in.name) + existing_ticket: str | None = ticket_handler.parse_ticket_number(order_in.name) if existing_ticket and order_service.store.get_order_by_ticket_id(existing_ticket): raise OrderExistsError(f"Order with ticket id {existing_ticket} already exists.") diff --git a/cg/server/ext.py b/cg/server/ext.py index 3200e7742a..c8cfda6286 100644 --- a/cg/server/ext.py +++ b/cg/server/ext.py @@ -6,12 +6,11 @@ from flask_wtf.csrf import CSRFProtect from cg.apps.lims import LimsAPI -from cg.apps.osticket import OsTicket from cg.apps.tb.api import TrailblazerAPI +from cg.clients.freshdesk.freshdesk_client import FreshdeskClient +from cg.meta.orders.ticket_handler import TicketHandler +from cg.server.app_config import app_config from cg.services.delivery_message.delivery_message_service import DeliveryMessageService -from cg.services.sample_run_metrics_service.sample_run_metrics_service import ( - SampleRunMetricsService, -) from cg.services.orders.order_service.order_service import OrderService from cg.services.orders.order_summary_service.order_summary_service import ( OrderSummaryService, @@ -20,6 +19,9 @@ OrderSubmitterRegistry, setup_order_submitter_registry, ) +from cg.services.sample_run_metrics_service.sample_run_metrics_service import ( + SampleRunMetricsService, +) from cg.services.sample_service.sample_service import SampleService from cg.store.database import initialize_database from cg.store.store import Store @@ -84,7 +86,6 @@ def init_app(self, app): admin = Admin(name="Clinical Genomics") lims = FlaskLims() -osticket = OsTicket() analysis_client = AnalysisClient() delivery_message_service = DeliveryMessageService(store=db, trailblazer_api=analysis_client) summary_service = OrderSummaryService(store=db, analysis_client=analysis_client) @@ -95,3 +96,12 @@ def init_app(self, app): lims=lims, status_db=db, ) +freshdesk_client = FreshdeskClient( + base_url=app_config.freshdesk_url, api_key=app_config.freshdesk_api_key +) +ticket_handler = TicketHandler( + db=db, + client=freshdesk_client, + system_email_id=app_config.freshdesk_order_email_id, + env=app_config.freshdesk_environment, +) diff --git a/tests/apps/test_osticket.py b/tests/apps/test_osticket.py deleted file mode 100644 index c16f21b2ca..0000000000 --- a/tests/apps/test_osticket.py +++ /dev/null @@ -1,31 +0,0 @@ -""" Test the osticket app """ - -import logging - -import pytest - -from cg.apps.osticket import OsTicket -from cg.exc import TicketCreationError - - -def test_osticket_respone_500(monkeypatch, caplog, response): - """Test that we log properly when we get a non successful response""" - - # GIVEN a ticket server always gives a failure in response - osticket_api = OsTicket() - monkeypatch.setattr("requests.post", lambda *args, **kwargs: response) - - # WHEN we call open_ticket with ok ticket data - caplog.set_level(logging.ERROR) - with pytest.raises(TicketCreationError): - osticket_api.open_ticket( - name="dummy_name", - email="dummy_email", - subject="dummy_subject", - message="dummy_message", - attachment={}, - ) - - # THEN the response text and reason was logged and a ticket creation error raised - assert response.reason in caplog.text - assert response.text in caplog.text diff --git a/tests/conftest.py b/tests/conftest.py index 8ca5b26c0f..3f4b3bd4bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,12 +75,12 @@ from tests.mocks.hk_mock import MockHousekeeperAPI from tests.mocks.limsmock import LimsSample, LimsUDF, MockLimsAPI from tests.mocks.madeline import MockMadelineAPI -from tests.mocks.osticket import MockOsTicket from tests.mocks.process_mock import ProcessMock from tests.mocks.scout import MockScoutAPI from tests.mocks.tb_mock import MockTB from tests.small_helpers import SmallHelpers from tests.store_helpers import StoreHelpers +from cg.clients.freshdesk.freshdesk_client import FreshdeskClient LOG = logging.getLogger(__name__) multiqc_json_file = "multiqc_data.json" @@ -645,11 +645,10 @@ def ticket_id() -> str: @pytest.fixture -def osticket(ticket_id: str) -> MockOsTicket: - """Return a api that mock the os ticket api.""" - api = MockOsTicket() - api.set_ticket_nr(ticket_id) - return api +def freshdesk_client() -> FreshdeskClient: + """Return a FreshdeskClient instance with mock parameters.""" + client = FreshdeskClient(base_url="https://mock.freshdesk.com", api_key="mock_api_key") + return client # Files fixtures diff --git a/tests/meta/orders/conftest.py b/tests/meta/orders/conftest.py index 24add743eb..5857e67a8a 100644 --- a/tests/meta/orders/conftest.py +++ b/tests/meta/orders/conftest.py @@ -2,6 +2,7 @@ import pytest +from cg.clients.freshdesk.freshdesk_client import FreshdeskClient from cg.meta.orders import OrdersAPI from cg.meta.orders.ticket_handler import TicketHandler from cg.services.orders.submitters.order_submitter_registry import ( @@ -10,27 +11,31 @@ ) from cg.store.store import Store from tests.mocks.limsmock import MockLimsAPI -from tests.mocks.osticket import MockOsTicket + + +@pytest.fixture +def freshdesk_client(): + return FreshdeskClient(base_url="https://mock.freshdesk.com", api_key="mock_api_key") @pytest.fixture(scope="function") def orders_api( - base_store, - osticket: MockOsTicket, + base_store: Store, + ticket_handler: TicketHandler, lims_api: MockLimsAPI, order_submitter_registry: OrderSubmitterRegistry, ) -> OrdersAPI: return OrdersAPI( lims=lims_api, status=base_store, - osticket=osticket, + ticket_handler=ticket_handler, submitter_registry=order_submitter_registry, ) @pytest.fixture -def ticket_handler(store: Store, osticket: MockOsTicket) -> TicketHandler: - return TicketHandler(status_db=store, osticket_api=osticket) +def ticket_handler(store: Store, freshdesk_client: FreshdeskClient) -> TicketHandler: + return TicketHandler(db=store, client=freshdesk_client, system_email_id=12345, env="production") @pytest.fixture diff --git a/tests/meta/orders/test_meta_orders_api.py b/tests/meta/orders/test_meta_orders_api.py index 885753ef03..04ffe40588 100644 --- a/tests/meta/orders/test_meta_orders_api.py +++ b/tests/meta/orders/test_meta_orders_api.py @@ -1,8 +1,9 @@ import datetime as dt -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch import pytest +from cg.clients.freshdesk.models import TicketResponse from cg.constants import DataDelivery from cg.constants.constants import Workflow from cg.constants.subject import Sex @@ -27,6 +28,22 @@ def monkeypatch_process_lims(monkeypatch, order_data) -> None: ) +def mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id: str): + """Helper function to mock Freshdesk ticket creation.""" + mock_create_ticket.return_value = TicketResponse( + id=int(ticket_id), + description="This is a test description.", + subject="Support needed..", + status=2, + priority=1, + ) + + +def mock_freshdesk_reply_to_ticket(mock_reply_to_ticket): + """Helper function to mock Freshdesk reply to ticket.""" + mock_reply_to_ticket.return_value = None + + def test_too_long_order_name(): # GIVEN order with more than allowed characters name long_name = "A super long order name that is longer than sixty-four characters." @@ -38,7 +55,6 @@ def test_too_long_order_name(): OrderIn(name=long_name, customer="", comment="", samples=[]) -@patch("cg.meta.orders.ticket_handler.FormDataRequest.submit", return_value=None) @pytest.mark.parametrize( "order_type", [ @@ -55,37 +71,44 @@ def test_too_long_order_name(): ], ) def test_submit( - mail_patch, all_orders_to_submit: dict, base_store: Store, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, order_type: OrderType, orders_api: OrdersAPI, ticket_id: str, user_mail: str, user_name: str, ): - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) + with patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" + ) as mock_create_ticket, patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" + ) as mock_reply_to_ticket: + mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) + mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) - # GIVEN an order and an empty store - assert not base_store._get_query(table=Sample).first() + order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) + monkeypatch_process_lims(monkeypatch, order_data) - # WHEN submitting the order + # GIVEN an order and an empty store + assert not base_store._get_query(table=Sample).first() - result = orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) + # WHEN submitting the order + + result = orders_api.submit( + project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail + ) - # THEN the result should contain the ticket number for the order - for record in result["records"]: - if isinstance(record, Pool): - assert record.ticket == ticket_id - elif isinstance(record, Sample): - assert record.original_ticket == ticket_id - elif isinstance(record, Case): - for link_obj in record.links: - assert link_obj.sample.original_ticket == ticket_id + # THEN the result should contain the ticket number for the order + for record in result["records"]: + if isinstance(record, Pool): + assert record.ticket == ticket_id + elif isinstance(record, Sample): + assert record.original_ticket == ticket_id + elif isinstance(record, Case): + for link_obj in record.links: + assert link_obj.sample.original_ticket == ticket_id @pytest.mark.parametrize( @@ -99,22 +122,24 @@ def test_submit_ticketexception( user_mail: str, user_name: str, ): - # GIVEN an order that does not have a name (ticket_nr) - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - order_data.name = "dummy_name" - with patch("cg.apps.osticket.OsTicket") as os_mock: - orders_api.ticket_handler.osticket = os_mock.return_value - orders_api.ticket_handler.osticket.open_ticket.side_effect = TicketCreationError("ERROR") - - # WHEN the order is submitted and a TicketCreationError raised - # THEN the TicketCreationError is not excepted - with pytest.raises(TicketCreationError): - orders_api.submit( - project=order_type, - order_in=order_data, - user_name=user_name, - user_mail=user_mail, - ) + # GIVEN a mock Freshdesk ticket creation that raises TicketCreationError + with patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket", + side_effect=TicketCreationError("ERROR"), + ): + # GIVEN an order that does not have a name (ticket_nr) + order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) + order_data.name = "dummy_name" + + # WHEN the order is submitted and a TicketCreationError raised + # THEN the TicketCreationError is not excepted + with pytest.raises(TicketCreationError): + orders_api.submit( + project=order_type, + order_in=order_data, + user_name=user_name, + user_mail=user_mail, + ) @pytest.mark.parametrize( @@ -123,7 +148,7 @@ def test_submit_ticketexception( ) def test_submit_illegal_sample_customer( all_orders_to_submit: dict, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, order_type: OrderType, orders_api: OrdersAPI, sample_store: Store, @@ -132,7 +157,6 @@ def test_submit_illegal_sample_customer( ): order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) monkeypatch_process_lims(monkeypatch, order_data) - orders_api.ticket_handler = Mock() # GIVEN we have an order with a customer that is not in the same customer group as customer # that the samples originate from new_customer = sample_store.add_customer( @@ -161,60 +185,65 @@ def test_submit_illegal_sample_customer( ) -@patch("cg.meta.orders.ticket_handler.FormDataRequest.submit", return_value=None) @pytest.mark.parametrize( "order_type", [OrderType.MIP_DNA, OrderType.MIP_RNA, OrderType.BALSAMIC], ) def test_submit_scout_legal_sample_customer( - mail_patch, all_orders_to_submit: dict, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, order_type: OrderType, orders_api: OrdersAPI, sample_store: Store, user_mail: str, user_name: str, + ticket_id: str, ): - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) - orders_api.ticket_handler = Mock() - # GIVEN we have an order with a customer that is in the same customer group as customer - # that the samples originate from - collaboration = sample_store.add_collaboration("customer999only", "customer 999 only group") - sample_store.session.add(collaboration) - sample_customer = sample_store.add_customer( - "customer1", - "customer 1", - scout_access=True, - invoice_address="dummy street 1", - invoice_reference="dummy nr", - ) - order_customer = sample_store.add_customer( - "customer2", - "customer 2", - scout_access=True, - invoice_address="dummy street 2", - invoice_reference="dummy nr", - ) - sample_customer.collaborations.append(collaboration) - order_customer.collaborations.append(collaboration) - sample_store.session.add(sample_customer) - sample_store.session.add(order_customer) - existing_sample: Sample = sample_store._get_query(table=Sample).first() - existing_sample.customer = sample_customer - sample_store.session.commit() - order_data.customer = order_customer.internal_id - - for sample in order_data.samples: - sample.internal_id = existing_sample.internal_id - break - - # WHEN calling submit - # THEN an OrderError should not be raised on illegal customer - orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) + with patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" + ) as mock_create_ticket, patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" + ) as mock_reply_to_ticket: + mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) + mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) + order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) + monkeypatch_process_lims(monkeypatch, order_data) + # GIVEN we have an order with a customer that is in the same customer group as customer + # that the samples originate from + collaboration = sample_store.add_collaboration("customer999only", "customer 999 only group") + sample_store.session.add(collaboration) + sample_customer = sample_store.add_customer( + "customer1", + "customer 1", + scout_access=True, + invoice_address="dummy street 1", + invoice_reference="dummy nr", + ) + order_customer = sample_store.add_customer( + "customer2", + "customer 2", + scout_access=True, + invoice_address="dummy street 2", + invoice_reference="dummy nr", + ) + sample_customer.collaborations.append(collaboration) + order_customer.collaborations.append(collaboration) + sample_store.session.add(sample_customer) + sample_store.session.add(order_customer) + existing_sample: Sample = sample_store._get_query(table=Sample).first() + existing_sample.customer = sample_customer + sample_store.session.commit() + order_data.customer = order_customer.internal_id + + for sample in order_data.samples: + sample.internal_id = existing_sample.internal_id + break + + # WHEN calling submit + # THEN an OrderError should not be raised on illegal customer + orders_api.submit( + project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail + ) @pytest.mark.parametrize( @@ -223,7 +252,7 @@ def test_submit_scout_legal_sample_customer( ) def test_submit_duplicate_sample_case_name( all_orders_to_submit: dict, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, order_type: OrderType, orders_api: OrdersAPI, ticket_id: str, @@ -234,7 +263,6 @@ def test_submit_duplicate_sample_case_name( order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) store = orders_api.status customer: Customer = store.get_customer_by_internal_id(customer_internal_id=order_data.customer) - orders_api.ticket_handler = Mock() for sample in order_data.samples: case_id = sample.family_name if not store.get_case_by_name_and_customer(customer=customer, case_name=case_id): @@ -262,72 +290,85 @@ def test_submit_duplicate_sample_case_name( ) -@patch("cg.meta.orders.ticket_handler.FormDataRequest.submit", return_value=None) @pytest.mark.parametrize( "order_type", [OrderType.FLUFFY], ) def test_submit_fluffy_duplicate_sample_case_name( - mail_patch, all_orders_to_submit: dict, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, order_type: OrderType, orders_api: OrdersAPI, user_mail: str, user_name: str, + ticket_id: str, ): - # GIVEN we have an order with a case that is already in the database - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) - - orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) + with patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" + ) as mock_create_ticket, patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" + ) as mock_reply_to_ticket: + mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) + mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) + # GIVEN we have an order with a case that is already in the database + order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) + monkeypatch_process_lims(monkeypatch, order_data) - # WHEN calling submit - # THEN an OrderError should be raised on duplicate case name - with pytest.raises(OrderError): orders_api.submit( - project=order_type, - order_in=order_data, - user_name=user_name, - user_mail=user_mail, + project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail ) + # WHEN calling submit + # THEN an OrderError should be raised on duplicate case name + with pytest.raises(OrderError): + orders_api.submit( + project=order_type, + order_in=order_data, + user_name=user_name, + user_mail=user_mail, + ) + -@patch("cg.meta.orders.ticket_handler.FormDataRequest.submit", return_value=None) def test_submit_unique_sample_case_name( - mail_patch, orders_api: OrdersAPI, mip_order_to_submit: dict, user_name: str, user_mail: str, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, + ticket_id: str, ): - # GIVEN we have an order with a case that is not existing in the database - order_data = OrderIn.parse_obj(obj=mip_order_to_submit, project=OrderType.MIP_DNA) + with patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" + ) as mock_create_ticket, patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" + ) as mock_reply_to_ticket: + mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) + mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) + + # GIVEN we have an order with a case that is not existing in the database + order_data = OrderIn.parse_obj(obj=mip_order_to_submit, project=OrderType.MIP_DNA) + + store = orders_api.status + + sample: MipDnaSample + for sample in order_data.samples: + case_id = sample.family_name + customer: Customer = store.get_customer_by_internal_id( + customer_internal_id=order_data.customer + ) + assert not store.get_case_by_name_and_customer(customer=customer, case_name=case_id) - store = orders_api.status + monkeypatch_process_lims(monkeypatch, order_data) - sample: MipDnaSample - for sample in order_data.samples: - case_id = sample.family_name - customer: Customer = store.get_customer_by_internal_id( - customer_internal_id=order_data.customer + # WHEN calling submit + orders_api.submit( + project=OrderType.MIP_DNA, + order_in=order_data, + user_name=user_name, + user_mail=user_mail, ) - assert not store.get_case_by_name_and_customer(customer=customer, case_name=case_id) - - monkeypatch_process_lims(monkeypatch, order_data) - # WHEN calling submit - orders_api.submit( - project=OrderType.MIP_DNA, - order_in=order_data, - user_name=user_name, - user_mail=user_mail, - ) - - # Then no exception about duplicate names should be thrown + # Then no exception about duplicate names should be thrown def test_validate_sex_inconsistent_sex( @@ -454,7 +495,6 @@ def test_validate_sex_unknown_new_sex( # THEN no OrderError should be raised on non-matching sex -@patch("cg.meta.orders.ticket_handler.FormDataRequest.submit", return_value=None) @pytest.mark.parametrize( "order_type", [ @@ -470,27 +510,34 @@ def test_validate_sex_unknown_new_sex( ], ) def test_submit_unique_sample_name( - mail_patch, all_orders_to_submit: dict, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, order_type: OrderType, orders_api: OrdersAPI, user_mail: str, user_name: str, + ticket_id: str, ): - # GIVEN we have an order with a sample that is not existing in the database - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - store = orders_api.status - assert not store._get_query(table=Sample).first() - - monkeypatch_process_lims(monkeypatch, order_data) - - # WHEN calling submit - orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) + with patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" + ) as mock_create_ticket, patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" + ) as mock_reply_to_ticket: + mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) + mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) + # GIVEN we have an order with a sample that is not existing in the database + order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) + store = orders_api.status + assert not store._get_query(table=Sample).first() + + monkeypatch_process_lims(monkeypatch, order_data) + + # WHEN calling submit + orders_api.submit( + project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail + ) - # Then no exception about duplicate names should be thrown + # Then no exception about duplicate names should be thrown @pytest.mark.parametrize( @@ -500,7 +547,7 @@ def test_submit_unique_sample_name( def test_sarscov2_submit_duplicate_sample_name( all_orders_to_submit: dict, helpers: StoreHelpers, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, order_type: OrderType, orders_api: OrdersAPI, user_mail: str, @@ -536,7 +583,6 @@ def store_samples_with_names_from_order(store: Store, helpers: StoreHelpers, ord store.session.commit() -@patch("cg.meta.orders.ticket_handler.FormDataRequest.submit", return_value=None) @pytest.mark.parametrize( "order_type", [ @@ -549,24 +595,30 @@ def store_samples_with_names_from_order(store: Store, helpers: StoreHelpers, ord ], ) def test_not_sarscov2_submit_duplicate_sample_name( - mail_patch, all_orders_to_submit: dict, helpers: StoreHelpers, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, order_type: OrderType, orders_api: OrdersAPI, - sample_store: Store, user_mail: str, user_name: str, + ticket_id: str, ): - # GIVEN we have an order with samples that is already in the database - order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) - monkeypatch_process_lims(monkeypatch, order_data) - store_samples_with_names_from_order(orders_api.status, helpers, order_data) - - # WHEN calling submit - orders_api.submit( - project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail - ) + with patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.create_ticket" + ) as mock_create_ticket, patch( + "cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket" + ) as mock_reply_to_ticket: + mock_freshdesk_ticket_creation(mock_create_ticket, ticket_id) + mock_freshdesk_reply_to_ticket(mock_reply_to_ticket) + # GIVEN we have an order with samples that is already in the database + order_data = OrderIn.parse_obj(obj=all_orders_to_submit[order_type], project=order_type) + monkeypatch_process_lims(monkeypatch, order_data) + store_samples_with_names_from_order(orders_api.status, helpers, order_data) + + # WHEN calling submit + orders_api.submit( + project=order_type, order_in=order_data, user_name=user_name, user_mail=user_mail + ) - # THEN no OrderError should be raised on duplicate sample name + # THEN no OrderError should be raised on duplicate sample name diff --git a/tests/mocks/osticket.py b/tests/mocks/osticket.py deleted file mode 100644 index 7ed223fc44..0000000000 --- a/tests/mocks/osticket.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Mock the os ticket api""" - -import logging -import os.path - -from flask import Flask - -from cg.apps.osticket import OsTicket -from cg.exc import TicketCreationError - -LOG = logging.getLogger(__name__) - - -class MockOsTicket(OsTicket): - """Interface to ticket system.""" - - def __init__(self): - self.headers = None - self.url = None - self.osticket_email = "james.holden@scilifelab.se" - self.mail_container_uri = "dummy_uri" - self._ticket_nr: str = "123456" - self._should_fail: bool = False - self._return_none: bool = False - self.email_uri = "http://localhost:0000/sendmail" - - def set_ticket_nr(self, ticket_id: str) -> None: - self._ticket_nr = ticket_id - - def init_app(self, app: Flask): - """Initialize the API in Flask.""" - - def setup( - self, - api_key: str = None, - domain: str = None, - osticket_email: str = None, - email_uri: str = None, - ): - """Initialize the API.""" - self.headers = {"X-API-Key": api_key} - self.url = os.path.join(domain, "api/tickets.json") - - def open_ticket( - self, attachment: dict, email: str, message: str, name: str, subject: str - ) -> str | None: - """Open a new ticket through the REST API.""" - if self._should_fail: - LOG.error("res.text: %s, reason: %s", self._ticket_nr, "Unknown reason") - raise TicketCreationError("FAIL") - if self._return_none: - return None - - return self._ticket_nr diff --git a/tests/server/conftest.py b/tests/server/conftest.py index b0f1e107a4..2c1290e47b 100644 --- a/tests/server/conftest.py +++ b/tests/server/conftest.py @@ -28,9 +28,6 @@ os.environ["TRAILBLAZER_HOST"] = "dummy_value" os.environ["CG_SECRET_KEY"] = "dummy_value" os.environ["GUNICORN_BIND"] = "0.0.0.0:8000" -os.environ["OSTICKET_API_KEY"] = "dummy_value" -os.environ["OSTICKET_API_URL"] = "dummy_value" -os.environ["OSTICKET_DOMAIN"] = "dummy_value" os.environ["SUPPORT_SYSTEM_EMAIL"] = "dummy_value" os.environ["EMAIL_URI"] = "dummy_value" From f19de7de237988e40c2a57a6e5801a394961d0a9 Mon Sep 17 00:00:00 2001 From: Clinical Genomics Bot Date: Tue, 3 Sep 2024 12:05:02 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Bump=20version:=2062.2.4=20=E2=86=92=2062.2?= =?UTF-8?q?.5=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- cg/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 52137f77aa..d101e6cae2 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 62.2.4 +current_version = 62.2.5 commit = True tag = True tag_name = v{new_version} diff --git a/cg/__init__.py b/cg/__init__.py index a7779ad0db..7870f9e682 100644 --- a/cg/__init__.py +++ b/cg/__init__.py @@ -1,2 +1,2 @@ __title__ = "cg" -__version__ = "62.2.4" +__version__ = "62.2.5" diff --git a/pyproject.toml b/pyproject.toml index b2e27b3f36..7cfe49704f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "cg" -version = "62.2.4" +version = "62.2.5" description = "Clinical Genomics command center" authors = ["Clinical Genomics "] readme = "README.md"