diff --git a/src/api_service/mrna_service.py b/src/api_service/mrna_service.py index 97690814..bb9027bc 100644 --- a/src/api_service/mrna_service.py +++ b/src/api_service/mrna_service.py @@ -12,7 +12,7 @@ TermRelationType, ) import modulector_sdk as modulector -from modulector_sdk import PaginatedResponse +from modulector_sdk import PaginatedResponse, SupportType class MRNAService(object): @@ -101,6 +101,29 @@ def get_mirna_target_interactions( logging.exception(ex) return self._paginated_to_dict(None) + def get_mirna_target_validations( + self, + mirna: Optional[str] = None, + target: Optional[str] = None, + support_type: Optional[str] = None, + experiment: Optional[str] = None, + ordering: Optional[str] = None, + page: Optional[int] = None, + page_size: Optional[int] = None, + ) -> Dict: + """Get experimentally validated miRNA-target interactions from miRTarBase.""" + result = modulector.get_mirna_target_validations( + mirna=mirna, + target=target, + support_type=cast(Optional[SupportType], support_type), + experiment=experiment, + ordering=ordering, + page=page, + page_size=page_size, + base_url=self._modulector_base_url, + ) + return self._paginated_to_dict(result) + def get_diseases(self, mirna: Optional[str] = None, page: Optional[int] = None, page_size: Optional[int] = None) -> Dict: """Get miRNA disease associations from Modulector SDK.""" @@ -310,4 +333,4 @@ def get_drugs_regulating_gene(self, gene_id: str) -> Optional[Dict]: return None -global_mrna_service = MRNAService() \ No newline at end of file +global_mrna_service = MRNAService() diff --git a/src/api_service/serializers.py b/src/api_service/serializers.py index 41e0decc..50ddd1eb 100644 --- a/src/api_service/serializers.py +++ b/src/api_service/serializers.py @@ -9,6 +9,18 @@ from django.contrib.auth import get_user_model +class MirnaTargetValidationSerializer(serializers.Serializer): + """Read-only representation of one miRTarBase validation record.""" + id = serializers.IntegerField(read_only=True) + mirtarbase_id = serializers.CharField(read_only=True) + mirna = serializers.CharField(read_only=True) + gene = serializers.CharField(read_only=True) + target_gene_entrez_id = serializers.JSONField(read_only=True, allow_null=True) + experiments = serializers.ListField(child=serializers.CharField(), read_only=True) + support_type = serializers.CharField(read_only=True) + pmid = serializers.JSONField(read_only=True, allow_null=True) + + class GeneGEMCombinationSerializer(serializers.ModelSerializer): """GeneGEMCombination serializer""" gene_extra_data = GeneForResultTableSerializer(source='gene', read_only=True) diff --git a/src/api_service/tests/test_mirna_target_validation.py b/src/api_service/tests/test_mirna_target_validation.py new file mode 100644 index 00000000..17319edb --- /dev/null +++ b/src/api_service/tests/test_mirna_target_validation.py @@ -0,0 +1,149 @@ +import json +from types import SimpleNamespace +from unittest.mock import patch + +import requests +from django.test import RequestFactory, SimpleTestCase +from django.urls import reverse + +from api_service.mrna_service import MRNAService +from api_service.serializers import MirnaTargetValidationSerializer +from api_service.views import get_mirna_target_validation_action + + +class MirnaTargetValidationTests(SimpleTestCase): + def setUp(self): + self.factory = RequestFactory() + + def request(self, params): + request = self.factory.get(reverse('mirna_target_validation'), params) + request.user = SimpleNamespace(is_authenticated=True) + return request + + def test_requires_mirna_or_target(self): + """The endpoint rejects requests that cannot identify a miRNA-target pair.""" + response = get_mirna_target_validation_action(self.request({})) + self.assertEqual(response.status_code, 400) + + def test_only_accepts_get(self): + """The endpoint only permits GET requests.""" + request = self.factory.post(reverse('mirna_target_validation')) + request.user = SimpleNamespace(is_authenticated=True) + response = get_mirna_target_validation_action(request) + self.assertEqual(response.status_code, 405) + + def test_forwards_query_parameters_and_serializes_paginated_results(self): + """The view forwards query parameters and returns read-only serialized records.""" + result = { + 'id': 1, + 'mirtarbase_id': 'MIRT000001', + 'mirna': 'hsa-miR-122-5p', + 'gene': 'SLC7A1', + 'target_gene_entrez_id': '10558', + 'experiments': ['Western blot'], + 'support_type': 'Functional MTI', + 'pmid': '12345678', + } + page = {'count': 1, 'next': '', 'previous': '', 'results': [result]} + with patch('api_service.views.global_mrna_service.get_mirna_target_validations', return_value=page) as get_data: + response = get_mirna_target_validation_action(self.request({ + 'mirna': 'hsa-miR-122-5p', + 'target': 'SLC7A1', + 'support_type': 'Functional MTI', + 'experiment': 'Western blot', + 'page': '2', + 'page_size': '20', + 'ordering': '-gene', + })) + + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.content), page) + get_data.assert_called_once_with( + mirna='hsa-miR-122-5p', target='SLC7A1', + support_type='Functional MTI', experiment='Western blot', + page='2', page_size='20', ordering='-gene', + ) + + def test_accepts_target_without_mirna(self): + """A target-only query is forwarded to Modulector and returns its empty page.""" + page = {'count': 0, 'next': '', 'previous': '', 'results': []} + with patch('api_service.views.global_mrna_service.get_mirna_target_validations', return_value=page) as get_data: + response = get_mirna_target_validation_action(self.request({'target': 'SLC7A1'})) + + self.assertEqual(response.status_code, 200) + get_data.assert_called_once_with( + mirna=None, target='SLC7A1', support_type=None, experiment=None, + ordering=None, page=None, page_size=None, + ) + + def test_preserves_upstream_pagination_links(self): + """Pagination metadata from Modulector is returned without rewriting its links.""" + page = { + 'count': 11, + 'next': 'https://web/mirna-target-validation/?target=SLC7A1&page=2', + 'previous': '', + 'results': [], + } + with patch('api_service.views.global_mrna_service.get_mirna_target_validations', return_value=page): + response = get_mirna_target_validation_action(self.request({'target': 'SLC7A1'})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.content), page) + + def test_accepts_multi_field_ordering(self): + """The view forwards a multi-field ordering value to the SDK service.""" + page = {'count': 0, 'next': '', 'previous': '', 'results': []} + with patch('api_service.views.global_mrna_service.get_mirna_target_validations', return_value=page) as get_data: + response = get_mirna_target_validation_action(self.request({ + 'target': 'SLC7A1', 'ordering': 'mirna, -gene' + })) + self.assertEqual(response.status_code, 200) + get_data.assert_called_once_with( + mirna=None, target='SLC7A1', support_type=None, experiment=None, + ordering='mirna, -gene', page=None, page_size=None, + ) + + def test_upstream_failure_is_not_reported_as_no_data(self): + """An upstream service failure is reported as a gateway error, not an empty result.""" + with patch('api_service.views.global_mrna_service.get_mirna_target_validations', + side_effect=requests.Timeout), \ + patch('api_service.views.logging.exception'): + response = get_mirna_target_validation_action(self.request({'target': 'SLC7A1'})) + self.assertEqual(response.status_code, 502) + + @patch('api_service.mrna_service.modulector.get_mirna_target_validations') + def test_service_calls_sdk(self, get_validations): + """The service forwards all supported miRTarBase filters and pagination to the SDK.""" + get_validations.return_value = SimpleNamespace( + count=1, next=None, previous=None, results=[{'gene': 'SLC7A1'}] + ) + result = MRNAService().get_mirna_target_validations( + mirna='hsa-miR-122-5p', target='SLC7A1', + support_type='Functional MTI', experiment='Western blot', + ordering='-gene', page=1 + ) + + self.assertEqual(result['results'], [{'gene': 'SLC7A1'}]) + self.assertEqual(result['count'], 1) + self.assertEqual(get_validations.call_args.kwargs['target'], 'SLC7A1') + self.assertEqual(get_validations.call_args.kwargs['support_type'], 'Functional MTI') + self.assertEqual(get_validations.call_args.kwargs['experiment'], 'Western blot') + self.assertEqual(get_validations.call_args.kwargs['ordering'], '-gene') + self.assertEqual(get_validations.call_args.kwargs['page'], 1) + + def test_result_serializer_is_read_only(self): + """The result serializer represents Modulector records without accepting writes.""" + record = { + 'id': 1, + 'mirtarbase_id': 'MIRT000001', + 'mirna': 'hsa-miR-122-5p', + 'gene': 'SLC7A1', + 'target_gene_entrez_id': None, + 'experiments': ['Western blot'], + 'support_type': 'Functional MTI', + 'pmid': None, + } + serializer = MirnaTargetValidationSerializer(data=record) + + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data, {}) diff --git a/src/api_service/urls.py b/src/api_service/urls.py index 5251e179..960f415c 100644 --- a/src/api_service/urls.py +++ b/src/api_service/urls.py @@ -36,6 +36,8 @@ # MiRNA Interaction path('mirna-target-interaction', views.get_mirna_target_interaction_action, name='mirna_target_interaction'), + path('mirna-target-validation', views.get_mirna_target_validation_action, + name='mirna_target_validation'), # MiRNA Disease associated path('mirna-disease', views.get_mirna_diseases_action, name='mirna_diseases'), path('mirna-drugs', views.get_mirna_drugs_action, name='mirna_drugs'), diff --git a/src/api_service/views.py b/src/api_service/views.py index c4549f86..119de807 100644 --- a/src/api_service/views.py +++ b/src/api_service/views.py @@ -1,5 +1,6 @@ import json import logging +import requests from typing import Optional, Dict, Tuple, List, Type, OrderedDict, Union, cast, Any import numpy as np @@ -53,7 +54,7 @@ from .permissions import ExperimentIsNotRunning from .serializers import ExperimentSerializer, ExperimentSerializerDetail, \ GeneMiRNACombinationSerializer, GeneCNACombinationSerializer, GeneMethylationCombinationSerializer, \ - ExperimentClinicalSourceSerializer, LimitedUserSerializer + ExperimentClinicalSourceSerializer, LimitedUserSerializer, MirnaTargetValidationSerializer from .tasks import eval_mrna_gem_experiment from .utils import get_experiment_source, file_type_to_experiment_type, get_cgds_dataset from institutions.models import Institution @@ -955,6 +956,34 @@ def get_mirna_target_interaction_action(request): return JsonResponse(data) +@login_required +@require_http_methods(['GET']) +def get_mirna_target_validation_action(request): + """Return experimentally validated miRNA-target interactions from miRTarBase.""" + mirna = request.GET.get('mirna') + target = request.GET.get('target') + + if not mirna and not target: + return JsonResponse(data={"error": "Param 'mirna' or 'target' are mandatory"}, status=400) + + try: + data = global_mrna_service.get_mirna_target_validations( + mirna=mirna, + target=target, + support_type=request.GET.get('support_type'), + experiment=request.GET.get('experiment'), + ordering=request.GET.get('ordering'), + page=request.GET.get('page'), + page_size=request.GET.get('page_size'), + ) + except (requests.RequestException, ValueError): + logging.exception('Modulector miRNA-target validation request failed') + return JsonResponse({'error': 'Modulector validation service is unavailable'}, status=502) + + data['results'] = MirnaTargetValidationSerializer(instance=data['results'], many=True).data + return JsonResponse(data) + + @login_required def get_mirna_diseases_action(request): """Searches in papers miRNA associations with diseases""" diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemDetailsModal.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemDetailsModal.tsx index 0cb9b90f..5bec065d 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemDetailsModal.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemDetailsModal.tsx @@ -16,6 +16,7 @@ import { GeneGemModalMenu } from './GeneGemModalMenu' import { NoClinicalData } from './survival-analysis/NoClinicalData' import { KaplanMeierChart } from './survival-analysis/KaplanMeier' import { MiRNATargetInteractionPanel } from './MiRNATargetInteractionPanel' +import { MiRNATargetValidationPanel } from './MiRNATargetValidationPanel' import { TryAgainSegment } from '../../../common/TryAgainSegment' import { COLOR_YELLOW_FILL, COLOR_YELLOW_STROKE } from '../../../../utils/constants' import { useIntl, IntlShape } from 'react-intl' @@ -38,6 +39,7 @@ enum ActiveItemMenu { STATISTICAL_PROPERTIES, ASSUMPTIONS, MIRNA_TARGET_INTERACTION, + MIRNA_TARGET_VALIDATION, MIRNA_INTERACTION, DISEASES_ASSOCIATION, DRUGS_ASSOCIATION, @@ -245,6 +247,8 @@ class GeneGemDetailsModal extends React.Component ) + case ActiveItemMenu.MIRNA_TARGET_VALIDATION: + return case ActiveItemMenu.MIRNA_INTERACTION: // NOTE: it's inside a React.Fragment to force componentDidMount execution // in PaginatedTable component. Otherwise, React will see miRNA interaction diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemModalMenu.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemModalMenu.tsx index 9b94c6f6..61cc8b87 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemModalMenu.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemModalMenu.tsx @@ -91,6 +91,23 @@ export const GeneGemModalMenu = (props: GeneGemModalMenuProps) => { /> + props.setActiveItem(ActiveItemMenu.MIRNA_TARGET_VALIDATION)} + > + {intl.formatMessage({ id: 'geneGemModalMenu.mirnaTargetValidation' })} + + + + props.setActiveItem(ActiveItemMenu.MIRNA_INTERACTION)} diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/MiRNATargetValidationPanel.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/MiRNATargetValidationPanel.tsx new file mode 100644 index 00000000..c52a88e9 --- /dev/null +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/MiRNATargetValidationPanel.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { Table } from 'semantic-ui-react' +import { RowHeader } from '../../../../utils/django_interfaces' +import { PaginatedTable } from '../../../common/PaginatedTable' +import { useIntl } from 'react-intl' +import { PubmedButton } from './PubmedButton' + +declare const urlMiRNATargetValidation: string + +/** One experimentally validated miRNA-target record returned by Modulector. */ +interface MiRNATargetValidation { + id: number, + mirtarbase_id: string, + mirna: string, + gene: string, + target_gene_entrez_id: string | number, + experiments: string[], + support_type: string, + pmid: string | number | null +} + +interface MiRNATargetValidationPanelProps { + /** Gene selected in the current experiment result row. */ + gene: string, + /** miRNA selected in the current experiment result row. */ + miRNA: string +} + +/** + * Shows miRTarBase experimental evidence for the selected miRNA-gene pair. + * @param props Selected miRNA and gene identifiers. + * @returns A paginated table of validation records. + */ +export const MiRNATargetValidationPanel = (props: MiRNATargetValidationPanelProps) => { + const intl = useIntl() + const headers: RowHeader[] = [ + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.mirtarbaseId' }) }, + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.supportType' }) }, + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.experiments' }) }, + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.targetGeneEntrezId' }) }, + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.pubmed' }), textAlign: 'center' } + ] + + return ( + + headerTitle={intl.formatMessage( + { id: 'miRNATargetValidationPanel.headerTitle' }, + { miRNA: props.miRNA, gene: props.gene } + )} + headers={headers} + urlToRetrieveData={urlMiRNATargetValidation} + queryParams={{ mirna: props.miRNA, target: props.gene }} + defaultSortProp={{ sortField: 'gene', sortOrderAscendant: true }} + mapFunction={(validation) => { + const pubmedUrl = validation.pmid + ? `https://pubmed.ncbi.nlm.nih.gov/${encodeURIComponent(String(validation.pmid))}/` + : '' + + return ( + + {validation.mirtarbase_id || '-'} + {validation.support_type || '-'} + {validation.experiments?.join(', ') || '-'} + {validation.target_gene_entrez_id || '-'} + + {pubmedUrl ? : '-'} + + + ) + }} + /> + ) +} diff --git a/src/frontend/static/frontend/src/locales/en.ts b/src/frontend/static/frontend/src/locales/en.ts index fdab788b..cb559d7f 100644 --- a/src/frontend/static/frontend/src/locales/en.ts +++ b/src/frontend/static/frontend/src/locales/en.ts @@ -1070,6 +1070,8 @@ export default { 'geneGemModalMenu.interaction': '{gem}-{gene} Interaction', 'geneGemModalMenu.interaction.info': 'Scores, given by external databases, that measures the association between {gene} and {gem}', + 'geneGemModalMenu.mirnaTargetValidation': 'miRTarBase validation', + 'geneGemModalMenu.mirnaTargetValidation.info': 'Experimental evidence for the interaction between {gem} and {gene} from miRTarBase', 'geneGemModalMenu.mirnaInteraction': '{gem} Interaction', 'geneGemModalMenu.mirnaInteraction.info': 'Find target interactions with {gem} in miRNA external databases', @@ -1149,6 +1151,12 @@ export default { 'miRNAInteractionPanel.includePubmeds': 'Include pubmeds', 'miRNAInteractionPanel.seeMorePapers': 'See more papers', + 'miRNATargetValidationPanel.headerTitle': 'Experimental validations for {miRNA} and {gene}', + 'miRNATargetValidationPanel.mirtarbaseId': 'miRTarBase ID', + 'miRNATargetValidationPanel.supportType': 'Support type', + 'miRNATargetValidationPanel.experiments': 'Experiments', + 'miRNATargetValidationPanel.targetGeneEntrezId': 'Target Entrez ID', + 'miRNATargetValidationPanel.pubmed': 'PubMed', // 122.MiRNATargetInteractionPanel 'miRNATargetInteractionPanel.noData': 'No data found for this miRNA and gene', diff --git a/src/frontend/static/frontend/src/locales/es.ts b/src/frontend/static/frontend/src/locales/es.ts index 388b0bfc..c83df6b7 100644 --- a/src/frontend/static/frontend/src/locales/es.ts +++ b/src/frontend/static/frontend/src/locales/es.ts @@ -1123,6 +1123,8 @@ export default { 'geneGemModalMenu.interaction': 'Interacción {gem}-{gene}', 'geneGemModalMenu.interaction.info': 'Puntuaciones proporcionadas por bases de datos externas que miden la asociación entre {gene} y {gem}', + 'geneGemModalMenu.mirnaTargetValidation': 'Validación miRTarBase', + 'geneGemModalMenu.mirnaTargetValidation.info': 'Evidencia experimental de la interacción entre {gem} y {gene} en miRTarBase', 'geneGemModalMenu.mirnaInteraction': 'Interacción de {gem}', 'geneGemModalMenu.mirnaInteraction.info': 'Encuentra interacciones objetivo con {gem} en bases de datos externas de miRNA', @@ -1202,6 +1204,12 @@ export default { 'miRNAInteractionPanel.includePubmeds': 'Incluir PubMed', 'miRNAInteractionPanel.seeMorePapers': 'Ver más artículos', + 'miRNATargetValidationPanel.headerTitle': 'Validaciones experimentales para {miRNA} y {gene}', + 'miRNATargetValidationPanel.mirtarbaseId': 'ID de miRTarBase', + 'miRNATargetValidationPanel.supportType': 'Tipo de soporte', + 'miRNATargetValidationPanel.experiments': 'Experimentos', + 'miRNATargetValidationPanel.targetGeneEntrezId': 'ID Entrez del gen objetivo', + 'miRNATargetValidationPanel.pubmed': 'PubMed', // 122.MiRNATargetInteractionPanel 'miRNATargetInteractionPanel.noData': 'No se encontraron datos para este miRNA y gen', diff --git a/src/frontend/templates/frontend/biomarkers.html b/src/frontend/templates/frontend/biomarkers.html index daabb396..9bbef470 100644 --- a/src/frontend/templates/frontend/biomarkers.html +++ b/src/frontend/templates/frontend/biomarkers.html @@ -81,6 +81,7 @@ const urlMiRNAeDrugs = "{% url 'mirna_drugs' %}" const urlMiRNADiseases = "{% url 'mirna_diseases' %}" const urlMiRNAInteraction = "{% url 'mirna_target_interaction' %}" + const urlMiRNATargetValidation = "{% url 'mirna_target_validation' %}" const downloadFileURL = "{% url 'download_user_file' %}" const urlGetUsersCandidatesLimitedBiomarker = "{% url 'user_candidates_limited_institution' %}" const urlGetInstitutionsNonInExperimentBiomarker = "{% url 'institution-non-experiments-list-biomarker' %}" diff --git a/src/frontend/templates/frontend/gem.html b/src/frontend/templates/frontend/gem.html index 918fa6a1..bd20e4ce 100644 --- a/src/frontend/templates/frontend/gem.html +++ b/src/frontend/templates/frontend/gem.html @@ -42,6 +42,7 @@ const urlGetExperimentData = "{% url 'get_experiment_data' %}" const urlCorrelationGraph = "{% url 'correlation_graph' %}" const urlMiRNAInteraction = "{% url 'mirna_target_interaction' %}" + const urlMiRNATargetValidation = "{% url 'mirna_target_validation' %}" const urlMiRNADiseases = "{% url 'mirna_diseases' %}" const urlMiRNAeDrugs = "{% url 'mirna_drugs' %}" const urlMiRNAData = "{% url 'mirna_data' %}"