Skip to content

Commit

Permalink
Hide cancelled samples from customers (#3510)(patch)
Browse files Browse the repository at this point in the history
  • Loading branch information
seallard authored Aug 5, 2024
1 parent 32f4fb1 commit 9f22fd0
Show file tree
Hide file tree
Showing 12 changed files with 232 additions and 83 deletions.
89 changes: 10 additions & 79 deletions cg/server/api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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/<order_type>", methods=["POST"])
Expand Down Expand Up @@ -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/<sample_id>")
def parse_sample(sample_id):
"""Return a single sample."""
Expand Down
2 changes: 2 additions & 0 deletions cg/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Empty file.
7 changes: 7 additions & 0 deletions cg/server/dto/samples/collaborator_samples_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pydantic import BaseModel


class CollaboratorSamplesRequest(BaseModel):
enquiry: str
customer: str
limit: int = 50
51 changes: 51 additions & 0 deletions cg/server/dto/samples/samples_response.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added cg/server/endpoints/__init__.py
Empty file.
18 changes: 18 additions & 0 deletions cg/server/endpoints/samples.py
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions cg/server/endpoints/utils.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 8 additions & 3 deletions cg/services/sample_service/sample_service.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -22,13 +25,15 @@ 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:
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)

def get_collaborator_samples(self, request: CollaboratorSamplesRequest) -> SamplesResponse:
samples: list[Sample] = self.store.get_collaborator_samples(request)
return create_samples_response(samples)
30 changes: 30 additions & 0 deletions cg/services/sample_service/utils.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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,
)
20 changes: 20 additions & 0 deletions cg/store/crud/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 9f22fd0

Please sign in to comment.