From 9f22fd0e633a69311fa4f834a2e7a3703286d52e Mon Sep 17 00:00:00 2001 From: Sebastian Allard Date: Mon, 5 Aug 2024 11:15:43 +0200 Subject: [PATCH] Hide cancelled samples from customers (#3510)(patch) --- cg/server/api.py | 89 +++---------------- cg/server/app.py | 2 + cg/server/dto/samples/__init__.py | 0 .../samples/collaborator_samples_request.py | 7 ++ cg/server/dto/samples/samples_response.py | 51 +++++++++++ cg/server/endpoints/__init__.py | 0 cg/server/endpoints/samples.py | 18 ++++ cg/server/endpoints/utils.py | 71 +++++++++++++++ cg/services/sample_service/sample_service.py | 11 ++- cg/services/sample_service/utils.py | 30 +++++++ cg/store/crud/read.py | 20 +++++ cg/store/filters/status_sample_filters.py | 16 +++- 12 files changed, 232 insertions(+), 83 deletions(-) create mode 100644 cg/server/dto/samples/__init__.py create mode 100644 cg/server/dto/samples/collaborator_samples_request.py create mode 100644 cg/server/dto/samples/samples_response.py create mode 100644 cg/server/endpoints/__init__.py create mode 100644 cg/server/endpoints/samples.py create mode 100644 cg/server/endpoints/utils.py diff --git a/cg/server/api.py b/cg/server/api.py index b6fc393f43..306a79b726 100644 --- a/cg/server/api.py +++ b/cg/server/api.py @@ -1,16 +1,11 @@ import json import logging import tempfile -from functools import wraps from http import HTTPStatus from pathlib import Path from typing import Any -import cachecontrol -import requests -from flask import Blueprint, abort, current_app, g, jsonify, make_response, request -from google.auth import exceptions -from google.auth.transport import requests as google_requests +from flask import Blueprint, abort, g, jsonify, make_response, request from google.oauth2 import id_token from pydantic.v1 import ValidationError from requests.exceptions import HTTPError @@ -44,7 +39,14 @@ from cg.server.dto.orders.orders_request import OrdersRequest from cg.server.dto.orders.orders_response import Order, OrdersResponse from cg.server.dto.sequencing_metrics.sequencing_metrics_request import SequencingMetricsRequest -from cg.server.ext import db, delivery_message_service, lims, order_service, osticket +from cg.server.endpoints.utils import before_request, is_public +from cg.server.ext import ( + db, + delivery_message_service, + lims, + order_service, + osticket, +) from cg.server.utils import parse_metrics_into_request from cg.store.models import ( Analysis, @@ -55,68 +57,11 @@ IlluminaSampleSequencingMetrics, Pool, Sample, - User, ) LOG = logging.getLogger(__name__) BLUEPRINT = Blueprint("api", __name__, url_prefix="/api/v1") - - -session = requests.session() -cached_session = cachecontrol.CacheControl(session) - - -def verify_google_token(token): - request = google_requests.Request(session=cached_session) - return id_token.verify_oauth2_token(id_token=token, request=request) - - -def is_public(route_function): - @wraps(route_function) - def public_endpoint(*args, **kwargs): - return route_function(*args, **kwargs) - - public_endpoint.is_public = True - return public_endpoint - - -@BLUEPRINT.before_request -def before_request(): - """Authorize API routes with JSON Web Tokens.""" - if not request.is_secure: - return abort( - make_response(jsonify(message="Only https requests accepted"), HTTPStatus.FORBIDDEN) - ) - - if request.method == "OPTIONS": - return make_response(jsonify(ok=True), HTTPStatus.NO_CONTENT) - - endpoint_func = current_app.view_functions[request.endpoint] - if getattr(endpoint_func, "is_public", None): - return - - auth_header = request.headers.get("Authorization") - if not auth_header: - return abort( - make_response(jsonify(message="no JWT token found on request"), HTTPStatus.UNAUTHORIZED) - ) - - jwt_token = auth_header.split("Bearer ")[-1] - try: - user_data = verify_google_token(jwt_token) - except (exceptions.OAuthError, ValueError) as e: - LOG.error(f"Error {e} occurred while decoding JWT token: {jwt_token}") - return abort( - make_response(jsonify(message="outdated login certificate"), HTTPStatus.UNAUTHORIZED) - ) - - user: User = db.get_user_by_email(user_data["email"]) - if user is None or not user.order_portal_login: - message = f"{user_data['email']} doesn't have access" - LOG.error(message) - return abort(make_response(jsonify(message=message), HTTPStatus.FORBIDDEN)) - - g.current_user = user +BLUEPRINT.before_request(before_request) @BLUEPRINT.route("/submit_order/", methods=["POST"]) @@ -298,20 +243,6 @@ def parse_samples(): return jsonify(samples=parsed_samples, total=len(samples)) -@BLUEPRINT.route("/samples_in_collaboration") -def parse_samples_in_collaboration(): - """Return samples in a customer group.""" - customer: Customer = db.get_customer_by_internal_id( - customer_internal_id=request.args.get("customer") - ) - samples: list[Sample] = db.get_samples_by_customer_id_and_pattern( - pattern=request.args.get("enquiry"), customers=customer.collaborators - ) - limit = int(request.args.get("limit", 50)) - parsed_samples: list[dict] = [sample.to_dict() for sample in samples[:limit]] - return jsonify(samples=parsed_samples, total=len(samples)) - - @BLUEPRINT.route("/samples/") def parse_sample(sample_id): """Return a single sample.""" diff --git a/cg/server/app.py b/cg/server/app.py index ecfd790aea..ecfa4e00a1 100644 --- a/cg/server/app.py +++ b/cg/server/app.py @@ -8,6 +8,7 @@ from cg.server import admin, api, ext, invoices from cg.server.app_config import app_config +from cg.server.endpoints.samples import SAMPLES_BLUEPRINT from cg.store.database import get_scoped_session_registry from cg.store.models import ( Analysis, @@ -86,6 +87,7 @@ def logged_in(blueprint, token): app.register_blueprint(api.BLUEPRINT) app.register_blueprint(invoices.BLUEPRINT, url_prefix="/invoices") app.register_blueprint(oauth_bp, url_prefix="/login") + app.register_blueprint(SAMPLES_BLUEPRINT) _register_admin_views() ext.csrf.exempt(api.BLUEPRINT) # Protected with Auth header already diff --git a/cg/server/dto/samples/__init__.py b/cg/server/dto/samples/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/server/dto/samples/collaborator_samples_request.py b/cg/server/dto/samples/collaborator_samples_request.py new file mode 100644 index 0000000000..ccd268856c --- /dev/null +++ b/cg/server/dto/samples/collaborator_samples_request.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class CollaboratorSamplesRequest(BaseModel): + enquiry: str + customer: str + limit: int = 50 diff --git a/cg/server/dto/samples/samples_response.py b/cg/server/dto/samples/samples_response.py new file mode 100644 index 0000000000..38155e11d0 --- /dev/null +++ b/cg/server/dto/samples/samples_response.py @@ -0,0 +1,51 @@ +from pydantic import BaseModel + +from cg.constants.subject import Sex + + +class CustomerDto(BaseModel): + internal_id: str + name: str + + +class SampleDTO(BaseModel): + name: str | None = None + internal_id: str | None = None + data_analysis: str | None = None + data_delivery: str | None = None + application: str | None = None + mother: str | None = None + father: str | None = None + family_name: str | None = None + case_internal_id: str | None = None + require_qc_ok: bool | None = None + sex: Sex | None = None + source: str | None = None + priority: str | None = None + formalin_fixation_time: int | None = None + post_formalin_fixation_time: int | None = None + tissue_block_size: str | None = None + cohorts: list[str] | None = None + phenotype_groups: list[str] | None = None + phenotype_terms: list[str] | None = None + subject_id: str | None = None + synopsis: str | None = None + age_at_sampling: int | None = None + comment: str | None = None + control: str | None = None + elution_buffer: str | None = None + container: str | None = None + container_name: str | None = None + well_position: str | None = None + volume: int | None = None + concentration_ng_ul: int | None = None + panels: list[str] | None = None + status: str | None = None + tumour: bool | None = None + reference_genome: str | None = None + customer: CustomerDto | None = None + + +class SamplesResponse(BaseModel): + samples: list[SampleDTO] + total: int diff --git a/cg/server/endpoints/__init__.py b/cg/server/endpoints/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/server/endpoints/samples.py b/cg/server/endpoints/samples.py new file mode 100644 index 0000000000..f302eab482 --- /dev/null +++ b/cg/server/endpoints/samples.py @@ -0,0 +1,18 @@ +from http import HTTPStatus +from flask import Blueprint, jsonify, request + +from cg.server.dto.samples.collaborator_samples_request import CollaboratorSamplesRequest +from cg.server.dto.samples.samples_response import SamplesResponse +from cg.server.endpoints.utils import before_request +from cg.server.ext import sample_service + + +SAMPLES_BLUEPRINT = Blueprint("samples", __name__, url_prefix="/api/v1") +SAMPLES_BLUEPRINT.before_request(before_request) + + +@SAMPLES_BLUEPRINT.route("/samples_in_collaboration") +def get_samples_in_collaboration(): + data = CollaboratorSamplesRequest.model_validate(request.values.to_dict()) + response: SamplesResponse = sample_service.get_collaborator_samples(data) + return jsonify(response.model_dump()), HTTPStatus.OK diff --git a/cg/server/endpoints/utils.py b/cg/server/endpoints/utils.py new file mode 100644 index 0000000000..f0940b8998 --- /dev/null +++ b/cg/server/endpoints/utils.py @@ -0,0 +1,71 @@ +import logging +from functools import wraps +from http import HTTPStatus + +import cachecontrol +import requests +from flask import abort, current_app, g, jsonify, make_response, request +from google.auth import exceptions +from google.auth.transport import requests as google_requests +from google.oauth2 import id_token + + +from cg.server.ext import db +from cg.store.models import User + +LOG = logging.getLogger(__name__) + +session = requests.session() +cached_session = cachecontrol.CacheControl(session) + + +def verify_google_token(token): + request = google_requests.Request(session=cached_session) + return id_token.verify_oauth2_token(id_token=token, request=request) + + +def is_public(route_function): + @wraps(route_function) + def public_endpoint(*args, **kwargs): + return route_function(*args, **kwargs) + + public_endpoint.is_public = True + return public_endpoint + + +def before_request(): + """Authorize API routes with JSON Web Tokens.""" + if not request.is_secure: + return abort( + make_response(jsonify(message="Only https requests accepted"), HTTPStatus.FORBIDDEN) + ) + + if request.method == "OPTIONS": + return make_response(jsonify(ok=True), HTTPStatus.NO_CONTENT) + + endpoint_func = current_app.view_functions[request.endpoint] + if getattr(endpoint_func, "is_public", None): + return + + auth_header = request.headers.get("Authorization") + if not auth_header: + return abort( + make_response(jsonify(message="no JWT token found on request"), HTTPStatus.UNAUTHORIZED) + ) + + jwt_token = auth_header.split("Bearer ")[-1] + try: + user_data = verify_google_token(jwt_token) + except (exceptions.OAuthError, ValueError) as e: + LOG.error(f"Error {e} occurred while decoding JWT token: {jwt_token}") + return abort( + make_response(jsonify(message="outdated login certificate"), HTTPStatus.UNAUTHORIZED) + ) + + user: User = db.get_user_by_email(user_data["email"]) + if user is None or not user.order_portal_login: + message = f"{user_data['email']} doesn't have access" + LOG.error(message) + return abort(make_response(jsonify(message=message), HTTPStatus.FORBIDDEN)) + + g.current_user = user diff --git a/cg/services/sample_service/sample_service.py b/cg/services/sample_service/sample_service.py index f34fc44f5e..4c1b2140d7 100644 --- a/cg/services/sample_service/sample_service.py +++ b/cg/services/sample_service/sample_service.py @@ -1,8 +1,11 @@ +from cg.server.dto.samples.collaborator_samples_request import CollaboratorSamplesRequest +from cg.server.dto.samples.samples_response import SamplesResponse +from cg.store.models import Sample from cg.services.sample_service.utils import ( + create_samples_response, get_cancel_comment, get_confirmation_message, ) -from cg.store.models import User from cg.store.store import Store @@ -22,7 +25,6 @@ def _add_cancel_comment(self, sample_id: int, user_email: str) -> None: self.store.update_sample_comment(sample_id=sample_id, comment=comment) 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: @@ -30,5 +32,8 @@ def cancel_samples(self, sample_ids: list[int], user_email: str) -> str: 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) + + def get_collaborator_samples(self, request: CollaboratorSamplesRequest) -> SamplesResponse: + samples: list[Sample] = self.store.get_collaborator_samples(request) + return create_samples_response(samples) diff --git a/cg/services/sample_service/utils.py b/cg/services/sample_service/utils.py index 5f22d70ad5..4bbbdd3f16 100644 --- a/cg/services/sample_service/utils.py +++ b/cg/services/sample_service/utils.py @@ -1,5 +1,8 @@ from datetime import datetime +from cg.server.dto.samples.samples_response import CustomerDto, SampleDTO, SamplesResponse +from cg.store.models import Sample + def get_cancel_comment(user_name: str) -> str: date: str = datetime.now().strftime("%Y-%m-%d") @@ -14,3 +17,30 @@ def get_confirmation_message(sample_ids: list[str], case_ids: list[str]) -> str: else: message += "No case contained additional samples." return message + + +def create_samples_response(samples: list[Sample]) -> SamplesResponse: + sample_dtos = [] + for sample in samples: + sample_dtos.append(create_sample_dto(sample)) + return SamplesResponse(samples=sample_dtos, total=len(samples)) + + +def create_sample_dto(sample: Sample) -> SampleDTO: + customer = CustomerDto( + internal_id=sample.customer.internal_id, + name=sample.customer.name, + ) + return SampleDTO( + comment=sample.comment, + customer=customer, + internal_id=sample.internal_id, + name=sample.name, + phenotype_groups=sample.phenotype_groups, + phenotype_terms=sample.phenotype_terms, + priority=sample.priority, + reference_genome=sample.reference_genome, + status=sample.state, + subject_id=sample.subject_id, + tumour=sample.is_tumour, + ) diff --git a/cg/store/crud/read.py b/cg/store/crud/read.py index 751fdc5105..b5691fc1a4 100644 --- a/cg/store/crud/read.py +++ b/cg/store/crud/read.py @@ -11,6 +11,7 @@ from cg.constants.constants import CaseActions, CustomerId, PrepCategory, SampleType from cg.exc import CaseNotFoundError, CgError, OrderNotFoundError, SampleNotFoundError from cg.server.dto.orders.orders_request import OrdersRequest +from cg.server.dto.samples.collaborator_samples_request import CollaboratorSamplesRequest from cg.store.base import BaseHandler from cg.store.filters.status_analysis_filters import AnalysisFilter, apply_analysis_filter from cg.store.filters.status_application_filters import ApplicationFilter, apply_application_filter @@ -607,6 +608,25 @@ def get_samples_by_customer_id_and_pattern( filter_functions=filter_functions, ).all() + def get_collaborator_samples(self, request: CollaboratorSamplesRequest) -> list[Sample]: + customer: Customer | None = self.get_customer_by_internal_id(request.customer) + collaborator_ids = [collaborator.id for collaborator in customer.collaborators] + + filters = [ + SampleFilter.BY_CUSTOMER_ENTRY_IDS, + SampleFilter.BY_INTERNAL_ID_OR_NAME_SEARCH, + SampleFilter.ORDER_BY_CREATED_AT_DESC, + SampleFilter.IS_NOT_CANCELLED, + SampleFilter.LIMIT, + ] + return apply_sample_filter( + samples=self._get_query(table=Sample), + customer_entry_ids=collaborator_ids, + search_pattern=request.enquiry, + filter_functions=filters, + limit=request.limit, + ).all() + def _get_samples_by_customer_and_subject_id_query( self, customer_internal_id: str, subject_id: str ) -> Query: diff --git a/cg/store/filters/status_sample_filters.py b/cg/store/filters/status_sample_filters.py index 87aedb0205..64855bc9ac 100644 --- a/cg/store/filters/status_sample_filters.py +++ b/cg/store/filters/status_sample_filters.py @@ -129,9 +129,11 @@ def filter_samples_by_internal_id_pattern( def filter_samples_by_internal_id_or_name_search( - samples: Query, search_pattern: str, **kwargs + samples: Query, search_pattern: str | None, **kwargs ) -> Query: """Return samples matching the internal id or name search.""" + if search_pattern is None: + return samples return samples.filter( or_( Sample.name.contains(search_pattern), @@ -157,6 +159,14 @@ def filter_samples_by_identifier_name_and_value( return samples.filter(getattr(Sample, identifier_name) == identifier_value) +def filter_out_cancelled_samples(samples: Query, **kwargs) -> Query: + return samples.filter(Sample.is_cancelled.is_(False)) + + +def apply_limit(samples: Query, limit: int, **kwargs) -> Query: + return samples.limit(limit) + + def apply_sample_filter( filter_functions: list[Callable], samples: Query, @@ -174,6 +184,7 @@ def apply_sample_filter( search_pattern: str | None = None, identifier_name: str = None, identifier_value: Any = None, + limit: int | None = None, ) -> Query: """Apply filtering functions to the sample queries and return filtered results.""" @@ -194,6 +205,7 @@ def apply_sample_filter( search_pattern=search_pattern, identifier_name=identifier_name, identifier_value=identifier_value, + limit=limit, ) return samples @@ -213,6 +225,7 @@ class SampleFilter(Enum): BY_SUBJECT_ID: Callable = filter_samples_by_subject_id DO_INVOICE: Callable = filter_samples_do_invoice HAS_NO_INVOICE_ID: Callable = filter_samples_without_invoice_id + IS_NOT_CANCELLED: Callable = filter_out_cancelled_samples IS_DELIVERED: Callable = filter_samples_is_delivered IS_NOT_DELIVERED: Callable = filter_samples_is_not_delivered IS_NOT_DOWN_SAMPLED: Callable = filter_samples_is_not_down_sampled @@ -224,6 +237,7 @@ class SampleFilter(Enum): IS_NOT_SEQUENCED: Callable = filter_samples_is_not_sequenced IS_TUMOUR: Callable = filter_samples_is_tumour IS_NOT_TUMOUR: Callable = filter_samples_is_not_tumour + LIMIT: Callable = apply_limit WITH_LOQUSDB_ID: Callable = filter_samples_with_loqusdb_id WITHOUT_LOQUSDB_ID: Callable = filter_samples_without_loqusdb_id WITH_TYPE: Callable = filter_samples_with_type