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