Skip to content
126 changes: 126 additions & 0 deletions alembic/versions/2026_07_28_eb2e90a251c5_add_order_fk_to_pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Add order fk to pool

Revision ID: eb2e90a251c5
Revises: 7e84083f6cb0
Create Date: 2026-07-28 11:38:21.383286

"""

import logging
from datetime import datetime
from typing import Annotated

import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from alembic import op

# revision identifiers, used by Alembic.
revision = "eb2e90a251c5"
down_revision = "7e84083f6cb0"
branch_labels = None
depends_on = None

PrimaryKeyInt = Annotated[int, mapped_column(primary_key=True)]
Str32 = Annotated[str, 32]
Str64 = Annotated[str, 64]
Text = Annotated[str, None]


class Base(DeclarativeBase):
type_annotation_map = {
Str32: sa.String(32),
Str64: sa.String(64),
}


class Customer(Base):
__tablename__ = "customer"
id: Mapped[PrimaryKeyInt]


class Order(Base):
"""Model for storing orders."""

__tablename__ = "order"

id: Mapped[PrimaryKeyInt]
customer_id: Mapped[int] = mapped_column(sa.ForeignKey("customer.id"))
order_date: Mapped[datetime] = mapped_column(default=datetime.now)
ticket_id: Mapped[int] = mapped_column(unique=True, index=True)
is_open: Mapped[bool] = mapped_column(default=True)


class Pool(Base):
__tablename__ = "pool"
__table_args__ = (sa.UniqueConstraint("order", "name", name="_order_name_uc"),)
comment: Mapped[Text | None]
created_at: Mapped[datetime | None] = mapped_column(default=datetime.now)
customer_id: Mapped[int] = mapped_column(sa.ForeignKey("customer.id"))
id: Mapped[PrimaryKeyInt]
name: Mapped[Str32]
order: Mapped[Str64]
order_id: Mapped[int] = mapped_column(sa.ForeignKey("order.id"))
db_order: Mapped[Order] = orm.relationship(foreign_keys=[order_id])
ordered_at: Mapped[datetime]
received_at: Mapped[datetime | None]
ticket: Mapped[Str32 | None]


LOG = logging.getLogger(__name__)


def upgrade():
"""
Adds a relationship between the Pool and the order table and uses the ticket column to link the two,
before dropping the (now obsolete) ticket column.
"""
bind: sa.Connection = op.get_bind()
session = Session(bind=bind)
op.add_column(
table_name="pool",
column=sa.Column(
sa.ForeignKey(
"order.id",
name="pool_order_fk",
),
name="order_id",
type_=sa.Integer,
nullable=True,
Comment thread
diitaz93 marked this conversation as resolved.
),
)
for pool in session.query(Pool).all():
if pool.ticket:
order: Order | None = session.query(Order).filter_by(ticket_id=int(pool.ticket)).first()
if not order:
LOG.info(f"Creating order with ticket_id {pool.ticket}")
order = Order(
customer_id=pool.customer_id,
Comment thread
islean marked this conversation as resolved.
is_open=False,
order_date=pool.ordered_at,
ticket_id=int(pool.ticket),
)
session.add(order)
pool.db_order = order
session.add(pool)
session.commit()
op.alter_column(
table_name="pool", column_name="order_id", nullable=False, existing_type=sa.Integer
)
op.drop_column(table_name="pool", column_name="ticket")


def downgrade():
bind: sa.Connection = op.get_bind()
session = Session(bind=bind)
op.add_column(
table_name="pool", column=sa.Column(name="ticket", type_=sa.VARCHAR(32), nullable=True)
)
for pool in session.query(Pool):
if pool.db_order:
Comment thread
islean marked this conversation as resolved.
pool.ticket = str(pool.db_order.ticket_id)
session.add(pool)
session.commit()
op.drop_constraint(constraint_name="pool_order_fk", table_name="pool", type_="foreignkey")
op.drop_column(table_name="pool", column_name="order_id")
54 changes: 40 additions & 14 deletions cg/server/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,18 @@
ticket_str = str(ticket_attr).strip()

# Freshdesk ticket IDs have >=7 digits
if len(ticket_str) >= 7:
ticket_link = f"{app_config.freshdesk_url}/a/tickets/{ticket_str}"
ticket_markup = Markup(f"<a href='{ticket_link}'>{ticket_str}</a>")
return ticket_markup
else:
return ticket_str
return _get_ticket_markups(ticket_str)


def view_ticket_link_via_order(_view, _context, model, attribute):
"""
Column formatter used to add hyperlink to the ticketing system, where the model has a
relationship to the Order table.
"""
order_attr, ticket_attr = attribute.split(".")
order = getattr(model, order_attr)
ticket_str = str(getattr(order, ticket_attr))
return _get_ticket_markups(ticket_str)


def view_tickets_links(unused1, unused2, model, unused3):
Expand All @@ -241,16 +247,21 @@
ticket_str = str(ticket).strip()

# Freshdesk ticket IDs have >=7 digits
if len(ticket_str) >= 7:
ticket_link = f"{app_config.freshdesk_url}/a/tickets/{ticket_str}"
ticket_markup = Markup(f"<a href='{ticket_link}'>{ticket_str}</a>")
tickets_markups.append(ticket_markup)
else:
tickets_markups.append(ticket_str)
tickets_markups.append(_get_ticket_markups(ticket_str))

return Markup(", ".join(tickets_markups))


def _get_ticket_markups(ticket_str: str) -> str:
# Freshdesk tickets are 7 digits
if len(ticket_str) >= 7:
ticket_link = f"{app_config.freshdesk_url}/a/tickets/{ticket_str}"
ticket_markup = Markup(f"<a href='{ticket_link}'>{ticket_str}</a>")
return ticket_markup
else:
return ticket_str


class ApplicationView(BaseView):
"""Admin view for Model.Application"""

Expand Down Expand Up @@ -743,14 +754,29 @@
"""Admin view for Model.Pool"""

column_default_sort = ("created_at", True)
column_editable_list = ["ticket"]
column_filters = ["customer.internal_id", "application_version.application"]
column_formatters = {
"application_version": view_application_link_via_application_version,
"customer": view_customer_link,
"invoice": InvoiceView.view_invoice_link,
"db_order.ticket_id": view_ticket_link_via_order,

Check failure on line 762 in cg/server/admin.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "db_order.ticket_id" 4 times.

See more on https://sonarcloud.io/project/issues?id=Clinical-Genomics_cg&issues=AZ-y6-5Vkb43fyhNcToQ&open=AZ-y6-5Vkb43fyhNcToQ&pullRequest=5222
}
column_searchable_list = ["name", "order", "ticket", "customer.internal_id"]
column_list = [
"application_version",
"customer",
"invoice",
"comment",
"created_at",
"delivered_at",
"name",
"no_invoice",
"order",
"ordered_at",
"received_at",
"db_order.ticket_id",
Comment thread
islean marked this conversation as resolved.
]
column_labels = {"db_order.ticket_id": "Ticket"}
column_searchable_list = ["name", "order", "db_order.ticket_id", "customer.internal_id"]


class SampleView(BaseView):
Expand Down
1 change: 1 addition & 0 deletions cg/services/deliver_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def _deliver(
try:
self.mark_as_delivered_service.mark_analyses(analyses=analyses, signature=signature)
self.mark_as_delivered_service.close_order_in_status_db_if_closable(order)
self.mark_as_delivered_service.mark_pools(order)
if not self._is_order_no_delivery(order):
self._interact_with_freshdesk(analyses=analyses, order=order)
except TrailblazerAnalysisDeliveryError as error:
Expand Down
7 changes: 7 additions & 0 deletions cg/services/mark_as_delivered_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ def unmark_analyses(self, analyses: list[Analysis]):
LOG.error(f"Failed to unmark analyses {trailblazer_ids} in Trailblazer.")
LOG.exception(error)

@staticmethod
def mark_pools(order: Order):
Comment thread
islean marked this conversation as resolved.
if not order.is_open:
for pool in order.pools:
Comment thread
islean marked this conversation as resolved.
LOG.info(f"Setting pool {pool.name} as delivered.")
pool.delivered_at = datetime.now()

def _is_order_closable(self, order: Order) -> bool:
"""
Return True only if
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ def store_order_data_in_status_db(self, order: OrderWithIndexedSamples) -> list[
ticket_id=str(db_order.ticket_id),
)
db_pool: Pool = self._create_db_pool(
db_order=db_order,
pool=pool,
order_name=order.name,
ticket_id=str(db_order.ticket_id),
customer=db_order.customer,
)
for sample in pool[1]:
Expand Down Expand Up @@ -136,8 +136,8 @@ def _create_db_case_for_pool(
def _create_db_pool(
self,
pool: tuple[str, list[IndexedSample]],
db_order: Order,
order_name: str,
Comment thread
diitaz93 marked this conversation as resolved.
ticket_id: str,
customer: Customer,
) -> Pool:
"""Return a Pool database object."""
Expand All @@ -150,7 +150,7 @@ def _create_db_pool(
name=pool[0],
order=order_name,
ordered=datetime.now(),
ticket=ticket_id,
db_order=db_order,
)

def _create_db_sample(
Expand Down
4 changes: 2 additions & 2 deletions cg/store/crud/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,20 +324,20 @@ def add_pool(
order: str,
Comment thread
diitaz93 marked this conversation as resolved.
ordered: datetime,
application_version: ApplicationVersion,
ticket: str = None,
comment: str = None,
received_at: datetime = None,
invoice_id: int = None,
no_invoice: bool = None,
delivered_at: datetime = None,
db_order: Order | None = None,
) -> Pool:
"""Build a new Pool record."""

new_record: Pool = Pool(
name=name,
ordered_at=ordered or datetime.now(),
order=order,
ticket=ticket,
db_order=db_order,
received_at=received_at,
comment=comment,
delivered_at=delivered_at,
Expand Down
10 changes: 9 additions & 1 deletion cg/store/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
order_case = Table(
"order_case",
Base.metadata,
Column("order_id", ForeignKey("order.id", ondelete="CASCADE"), nullable=False),

Check failure on line 110 in cg/store/models.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "order.id" 3 times.

See more on https://sonarcloud.io/project/issues?id=Clinical-Genomics_cg&issues=AZ-oz1nLdsAKk-AFaJMq&open=AZ-oz1nLdsAKk-AFaJMq&pullRequest=5222
Column("case_id", ForeignKey("case.id", ondelete="CASCADE"), nullable=False),
UniqueConstraint("order_id", "case_id", name="_order_case_uc"),
)
Expand Down Expand Up @@ -727,15 +727,20 @@
name: Mapped[Str32]
no_invoice: Mapped[bool | None] = mapped_column(default=False)
order: Mapped[Str64]
order_id: Mapped[int | None] = mapped_column(ForeignKey("order.id"))
db_order: Mapped["Order"] = orm.relationship(foreign_keys=[order_id])
ordered_at: Mapped[datetime]
received_at: Mapped[datetime | None]
ticket: Mapped[Str32 | None]

invoice: Mapped["Invoice | None"] = orm.relationship(back_populates="pools")

def to_dict(self):
return to_dict(model_instance=self)

@property
def ticket(self) -> str | None:
return str(self.db_order.ticket_id) if self.db_order else None


class Sample(Base, PriorityMixin):
__tablename__ = "sample"
Expand Down Expand Up @@ -1087,6 +1092,9 @@
analyses: Mapped[list[Analysis]] = orm.relationship(
back_populates="order", order_by="Analysis.created_at"
)
pools: Mapped[list[Pool]] = orm.relationship(
back_populates="db_order", order_by="Pool.ordered_at"
)

@property
def workflow(self) -> Workflow:
Expand Down
20 changes: 12 additions & 8 deletions tests/services/orders/submitter/test_order_submitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ def monkeypatch_process_lims(monkeypatch: pytest.MonkeyPatch, order: Order) -> N
)


def mock_freshdesk_ticket_creation(mock_create_ticket: Mock, ticket_id: str):
def mock_freshdesk_ticket_creation(mock_create_ticket: Mock, ticket_id: int):
"""Helper function to mock Freshdesk ticket creation."""
mock_create_ticket.return_value = TicketResponse(
id=int(ticket_id),
id=ticket_id,
cc_emails=["email@to.cc"],
description="This is a test description.",
subject="Support needed..",
Expand Down Expand Up @@ -225,7 +225,7 @@ def test_submit_order(
order_type: OrderType,
order_fixture: str,
order_submitter: OrderSubmitter,
ticket_id: str,
ticket_id_as_int: int,
customer_id: str,
request: pytest.FixtureRequest,
):
Expand All @@ -247,7 +247,9 @@ def test_submit_order(
"cg.clients.freshdesk.freshdesk_client.FreshdeskClient.reply_to_ticket"
) as mock_reply_to_ticket,
):
mock_freshdesk_ticket_creation(mock_create_ticket=mock_create_ticket, ticket_id=ticket_id)
mock_freshdesk_ticket_creation(
mock_create_ticket=mock_create_ticket, ticket_id=ticket_id_as_int
)
mock_freshdesk_reply_to_ticket(mock_reply_to_ticket)

# GIVEN a mock LIMS that returns project data and sample name mapping
Expand All @@ -271,17 +273,19 @@ def test_submit_order(
for record in result["records"]:
assert record.customer.internal_id == customer_id
if isinstance(record, Pool):
assert record.ticket == ticket_id
assert record.ticket == str(ticket_id_as_int)
is_pool_order = True
elif isinstance(record, Sample):
assert record.original_ticket == ticket_id
assert record.original_ticket == str(ticket_id_as_int)
elif isinstance(record, Case):
assert record.data_analysis == ORDER_TYPE_WORKFLOW_MAP[order_type]
for link_obj in record.links:
assert link_obj.sample.original_ticket == ticket_id
assert link_obj.sample.original_ticket == str(ticket_id_as_int)

# THEN the order should be stored in the database
assert store_to_submit_and_validate_orders.get_order_by_ticket_id(ticket_id=int(ticket_id))
assert store_to_submit_and_validate_orders.get_order_by_ticket_id(
ticket_id=ticket_id_as_int
)

# THEN the samples should be stored in the database
assert store_to_submit_and_validate_orders._get_query(table=Sample).first()
Expand Down
Loading
Loading