Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor cancel action #3507

Merged
merged 26 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cg/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ class CgDataError(CgError):
"""


class SampleNotFoundError(CgDataError):
"""
Exception raised when a sample is not found.
"""


class ChecksumFailedError(CgError):
"""
Exception raised when the checksums of two files are not equal.
Expand Down
66 changes: 6 additions & 60 deletions cg/server/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Module for Flask-Admin views"""

from datetime import datetime
from gettext import gettext

from flask import flash, redirect, request, session, url_for
Expand All @@ -10,8 +9,7 @@
from markupsafe import Markup

from cg.constants.constants import NG_UL_SUFFIX, CaseActions, DataDelivery, Workflow
from cg.server.ext import db
from cg.store.models import Sample
from cg.server.ext import db, sample_service
from cg.utils.flask.enum import SelectEnumField


Expand Down Expand Up @@ -598,64 +596,12 @@ def view_sample_link(unused1, unused2, model, unused3):
"Are you sure you want to cancel the selected samples?",
)
def cancel_samples(self, entry_ids: list[str]) -> None:
"""
Action for cancelling samples:
- Comments each sample being cancelled with date and user.
- Deletes any relationship between the cancelled samples and cases.
- Deletes any cases that only contain samples being cancelled.
"""
all_associated_case_ids: set = set()

for entry_id in entry_ids:
sample: Sample = db.get_sample_by_entry_id(entry_id=int(entry_id))

sample_case_ids: list[str] = [
case_sample.case.internal_id for case_sample in sample.links
]
all_associated_case_ids.update(sample_case_ids)

db.delete_relationships_sample(sample=sample)
self.write_cancel_comment(sample=sample)

case_ids: list[str] = list(all_associated_case_ids)
db.delete_cases_without_samples(case_internal_ids=case_ids)
cases_with_remaining_samples: list[str] = db.filter_cases_with_samples(case_ids=case_ids)

self.display_cancel_confirmation(
sample_entry_ids=entry_ids, remaining_cases=cases_with_remaining_samples
user_email: str | None = session.get("user_email")
message: str = sample_service.cancel_samples(
sample_ids=entry_ids,
user_email=user_email,
)

def write_cancel_comment(self, sample: Sample) -> None:
"""Add comment to sample with date and user cancelling the sample."""
user_name: str = db.get_user_by_email(session.get("user_email")).name
date: str = datetime.now().strftime("%Y-%m-%d")
comment: str = f"Cancelled {date} by {user_name}"

db.update_sample_comment(sample=sample, comment=comment)

def display_cancel_confirmation(
self, sample_entry_ids: list[str], remaining_cases: list[str]
) -> None:
"""Show a summary of the cancelled samples and any cases in which other samples were present."""
samples: str = "sample" if len(sample_entry_ids) == 1 else "samples"
cases: str = "case" if len(remaining_cases) == 1 else "cases"

message: str = f"Cancelled {len(sample_entry_ids)} {samples}. "
case_message: str = ""

for case_id in remaining_cases:
case_message = f"{case_message} {case_id},"

case_message = case_message.strip(",")

if remaining_cases:
message += (
f"Found {len(remaining_cases)} {cases} with additional samples: {case_message}."
)
else:
message += "No case contained additional samples."

flash(message=message)
flash(message)


class DeliveryView(BaseView):
Expand Down
2 changes: 2 additions & 0 deletions cg/server/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from cg.services.orders.order_summary_service.order_summary_service import (
OrderSummaryService,
)
from cg.services.sample_service.sample_service import SampleService
from cg.store.database import initialize_database
from cg.store.store import Store

Expand Down Expand Up @@ -81,3 +82,4 @@ def init_app(self, app):
delivery_message_service = DeliveryMessageService(store=db, trailblazer_api=analysis_client)
summary_service = OrderSummaryService(store=db, analysis_client=analysis_client)
order_service = OrderService(store=db, status_service=summary_service)
sample_service = SampleService(db)
34 changes: 34 additions & 0 deletions cg/services/sample_service/sample_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from cg.services.sample_service.utils import (
get_cancel_comment,
get_confirmation_message,
)
from cg.store.models import User
from cg.store.store import Store


class SampleService:

def __init__(self, store: Store):
self.store = store

def cancel_sample(self, sample_id: int, user_email: str) -> None:
self.store.mark_sample_as_cancelled(sample_id)
self.store.decouple_sample_from_cases(sample_id)
self._add_cancel_comment(sample_id=sample_id, user_email=user_email)

def _add_cancel_comment(self, sample_id: int, user_email: str) -> None:
if user := self.store.get_user_by_email(user_email):
comment = get_cancel_comment(user.name)
self.store.update_sample_comment(sample_id=sample_id, comment=comment)
seallard marked this conversation as resolved.
Show resolved Hide resolved

def cancel_samples(self, sample_ids: list[int], user_email: str) -> str:
"""Returns a cancellation confirmation message."""
case_ids = self.store.get_case_ids_for_samples(sample_ids)

for sample_id in sample_ids:
self.cancel_sample(sample_id=sample_id, user_email=user_email)

self.store.delete_cases_without_samples(case_ids)
remaining_cases = self.store.filter_cases_with_samples(case_ids)

return get_confirmation_message(sample_ids=sample_ids, case_ids=remaining_cases)
16 changes: 16 additions & 0 deletions cg/services/sample_service/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from datetime import datetime


def get_cancel_comment(user_name: str) -> str:
date: str = datetime.now().strftime("%Y-%m-%d")
return f"Cancelled {date} by {user_name}"


def get_confirmation_message(sample_ids: list[str], case_ids: list[str]) -> str:
message = f"Cancelled {len(sample_ids)} samples. "
if case_ids:
cases = ", ".join(case_ids)
message += f"Found {len(case_ids)} cases with additional samples: {cases}."
else:
message += "No case contained additional samples."
return message
13 changes: 6 additions & 7 deletions cg/store/crud/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@ def __init__(self, session: Session):
super().__init__(session=session)
self.session = session

def delete_relationships_sample(self, sample: Sample) -> None:
"""Delete relationships between all cases and the provided sample."""
if sample and sample.links:
for case_sample in sample.links:
self.session.delete(case_sample)
self.session.commit()

def delete_cases_without_samples(self, case_internal_ids: list[str]) -> None:
"""Delete any cases specified in case_ids without samples."""
for case_internal_id in case_internal_ids:
Expand All @@ -36,3 +29,9 @@ def delete_illumina_flow_cell(self, internal_id: str):
self.session.commit()
else:
raise ValueError(f"Illumina flow cell with internal id {internal_id} not found.")

def decouple_sample_from_cases(self, sample_id: int) -> None:
sample: Sample = self.get_sample_by_entry_id(sample_id)
for case_sample in sample.links:
self.session.delete(case_sample)
self.session.commit()
20 changes: 18 additions & 2 deletions cg/store/crud/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from cg.constants import SequencingRunDataAvailability, Workflow
from cg.constants.constants import CaseActions, CustomerId, PrepCategory, SampleType
from cg.exc import CaseNotFoundError, CgError, OrderNotFoundError
from cg.exc import CaseNotFoundError, CgError, OrderNotFoundError, SampleNotFoundError
from cg.server.dto.orders.orders_request import OrdersRequest
from cg.store.base import BaseHandler
from cg.store.filters.status_analysis_filters import AnalysisFilter, apply_analysis_filter
Expand Down Expand Up @@ -1009,12 +1009,17 @@ def get_cases_to_compress(self, date_threshold: datetime) -> list[Case]:

def get_sample_by_entry_id(self, entry_id: int) -> Sample:
"""Return a sample by entry id."""
return apply_sample_filter(
sample: Sample | None = apply_sample_filter(
filter_functions=[SampleFilter.BY_ENTRY_ID],
samples=self._get_query(table=Sample),
entry_id=entry_id,
).first()

if not sample:
LOG.error(f"Could not find sample with entry id {entry_id}")
raise SampleNotFoundError(f"Could not find sample with entry id {entry_id}")
return sample

def get_sample_by_internal_id(self, internal_id: str) -> Sample | None:
"""Return a sample by lims id."""
return apply_sample_filter(
Expand Down Expand Up @@ -1463,3 +1468,14 @@ def get_cases_for_sequencing_qc(self) -> list[Case]:
CaseFilter.HAS_SEQUENCE,
],
).all()

def get_case_ids_with_sample(self, sample_id: int) -> list[str]:
"""Return all case ids with a sample."""
sample: Sample = self.get_sample_by_entry_id(sample_id)
return [link.case.internal_id for link in sample.links] if sample else []

def get_case_ids_for_samples(self, sample_ids: list[int]) -> list[str]:
case_ids: list[str] = []
for sample_id in sample_ids:
case_ids.extend(self.get_case_ids_with_sample(sample_id))
return list(set(case_ids))
13 changes: 8 additions & 5 deletions cg/store/crud/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,10 @@ def __init__(self, session: Session):
super().__init__(session=session)
self.session = session

def update_sample_comment(self, sample: Sample, comment: str) -> None:
def update_sample_comment(self, sample_id: int, comment: str) -> None:
"""Update comment on sample with the provided comment."""
if sample.comment:
sample.comment = sample.comment + " " + comment
else:
sample.comment = comment
sample: Sample = self.get_sample_by_entry_id(sample_id)
sample.comment = f"{sample.comment} {comment}" if sample.comment else comment
islean marked this conversation as resolved.
Show resolved Hide resolved
self.session.commit()

def update_order_delivery(self, order_id: int, delivered: bool) -> Order:
Expand Down Expand Up @@ -71,3 +69,8 @@ def update_sample_sequenced_at(self, internal_id: str, date: datetime):
sample: Sample = self.get_sample_by_internal_id(internal_id)
sample.last_sequenced_at = date
self.session.commit()

def mark_sample_as_cancelled(self, sample_id: int) -> None:
sample: Sample = self.get_sample_by_entry_id(sample_id)
sample.is_cancelled = True
islean marked this conversation as resolved.
Show resolved Hide resolved
self.session.commit()
2 changes: 1 addition & 1 deletion tests/store/crud/delete/test_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_store_api_delete_relationships_between_sample_and_cases(
assert sample_in_multiple_cases

# WHEN removing the relationships between one sample and its cases
store_with_multiple_cases_and_samples.delete_relationships_sample(sample=sample_in_single_case)
store_with_multiple_cases_and_samples.decouple_sample_from_cases(sample_in_single_case.id)

# THEN it should no longer be associated with any cases, but other relationships should remain
results: list[CaseSample] = (
Expand Down
Loading