Skip to content
Open
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
17 changes: 16 additions & 1 deletion app/api/api/filters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.db.models.query_utils import Q
from django_filters import rest_framework as django_filters
from mapping.models import ScanReportValue, VisibilityChoices
from mapping.models import ScanReportField, ScanReportValue, VisibilityChoices
from rest_framework import filters


Expand Down Expand Up @@ -150,3 +150,18 @@ class Meta:
fields = {
"value": ["in", "icontains"],
}


class ScanReportFieldFilter(django_filters.FilterSet):
"""
Custom filterset for ScanReportField model.
"""

has_concepts = HasConceptsFilter()
creation_type = CreationTypeFilter()

class Meta:
model = ScanReportField
fields = {
"name": ["icontains"],
}
18 changes: 18 additions & 0 deletions app/api/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,24 @@ class Meta:
]


class ScanReportFieldListSerializerV3(serializers.ModelSerializer):
concepts = ScanReportConceptSerializerV2(many=True, read_only=True)
mapping_recommendations = MappingRecommendationSerializerV3(
many=True, read_only=True
)

class Meta:
model = ScanReportField
fields = [
"id",
"name",
"description_column",
"type_column",
"concepts",
"mapping_recommendations",
]


class ScanReportValueViewSerializerV2(serializers.ModelSerializer):
class Meta:
model = ScanReportValue
Expand Down
5 changes: 5 additions & 0 deletions app/api/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@
views.ScanReportConceptDetailV3.as_view(),
name="scan-report-concepts-detail",
),
path(
"v3/scanreports/<int:pk>/tables/<int:table_pk>/fields/",
views.ScanReportFieldIndexV3.as_view(),
name="scan-report-fields-v3",
),
path(r"user/me/", views.UserDetailView.as_view(), name="currentuser"),
path(r"v2/users/", views.UserViewSet.as_view(), name="users-list"),
path(r"v2/usersfilter/", views.UserFilterViewSet.as_view(), name="usersfilter"),
Expand Down
71 changes: 70 additions & 1 deletion app/api/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@
from services.storage_service import StorageService
from services.worker_service import get_worker_service

from api.filters import ScanReportAccessFilter, ScanReportValueFilter
from api.filters import (
ScanReportAccessFilter,
ScanReportFieldFilter,
ScanReportValueFilter,
)
from api.mixins import ScanReportPermissionMixin
from api.paginations import CustomPagination
from api.serializers import (
Expand All @@ -70,6 +74,7 @@
ScanReportEditSerializer,
ScanReportFieldEditSerializer,
ScanReportFieldListSerializerV2,
ScanReportFieldListSerializerV3,
ScanReportFilesSerializer,
ScanReportTableEditSerializer,
ScanReportTableListSerializerV2,
Expand Down Expand Up @@ -821,6 +826,70 @@ def get_serializer_class(self):
return super().get_serializer_class()


class ScanReportFieldIndexV3(ScanReportPermissionMixin, GenericAPIView, ListModelMixin):
"""
A view that provides a list of ScanReportField objects associated
with a specific ScanReportTable. Each field is returned with its
nested ``concepts`` and ``mapping_recommendations`` so the client
can render concept tags without follow-up requests. This view
supports filtering (including ``has_concepts`` and
``creation_type``), ordering, and pagination.

Attributes:
serializer_class (Serializer): The serializer class used for
serializing the ScanReportField objects (V3 — includes
nested concepts and mapping recommendations).
filterset_class (FilterSet): The filterset used for filtering,
including the ``has_concepts`` and ``creation_type``
filters.
filter_backends (list): List of filter backends used for
filtering and ordering.
ordering_fields (list): Fields that can be used for ordering
the results.
pagination_class (Pagination): The pagination class used for
paginating the results.

Methods:
get(request, *args, **kwargs):
Handles GET requests and retrieves the ScanReportTable
object based on the provided table_pk. Returns a list of
ScanReportField objects associated with the table.
get_queryset():
Returns the queryset of ScanReportField objects filtered by
the associated ScanReportTable, with ``select_related`` and
``prefetch_related`` applied to avoid N+1 queries when
serializing nested concepts and mapping recommendations.
list(request, *args, **kwargs):
Returns the paginated and serialized list of
ScanReportField objects. Caching is intentionally omitted
(unlike V2) so concept edits are reflected immediately.
"""

filterset_class = ScanReportFieldFilter
filter_backends = [DjangoFilterBackend, OrderingFilter]
ordering_fields = ["name", "description_column", "type_column"]
pagination_class = CustomPagination
serializer_class = ScanReportFieldListSerializerV3

@extend_schema(responses=ScanReportFieldListSerializerV3)
def get(self, request, *args, **kwargs):
self.table = get_object_or_404(ScanReportTable, pk=kwargs["table_pk"])
return self.list(request, *args, **kwargs)

def get_queryset(self):
return (
ScanReportField.objects.filter(scan_report_table=self.table)
.order_by("id")
.select_related("scan_report_table")
.prefetch_related(
"concepts",
"concepts__concept",
"mapping_recommendations",
"mapping_recommendations__concept",
)
)


class ScanReportValueListV2(ScanReportPermissionMixin, GenericAPIView, ListModelMixin):
"""
A view for listing ScanReportValue objects associated with a
Expand Down
80 changes: 80 additions & 0 deletions app/api/test/test_serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from api.serializers import (
MappingRecommendationSerializerV3,
ScanReportEditSerializer,
ScanReportFieldListSerializerV3,
ScanReportValueViewSerializerV3,
)
from data.models import Concept
Expand All @@ -14,6 +15,7 @@
MappingRecommendation,
Project,
ScanReport,
ScanReportConcept,
ScanReportField,
ScanReportTable,
ScanReportValue,
Expand Down Expand Up @@ -464,3 +466,81 @@ def test_scan_report_value_v3_serializer_includes_recommendations(self):
self.assertEqual(
recommendation_data["concept"]["concept_name"], self.concept.concept_name
)


class TestScanReportFieldSerializerV3(TestCase):
def setUp(self):
self.scan_report = ScanReport.objects.create(
dataset="Test Dataset",
visibility="PUBLIC",
)

self.table = ScanReportTable.objects.create(
scan_report=self.scan_report,
name="Test Table",
)

self.field = ScanReportField.objects.create(
scan_report_table=self.table,
name="Test Field",
description_column="Test Description",
type_column="string",
)

self.concept = Concept.objects.create(
concept_id=12345,
concept_name="Test Concept",
concept_code="TEST123",
domain_id="Test",
vocabulary_id="Test",
concept_class_id="Test",
standard_concept="S",
valid_start_date="2020-01-01",
valid_end_date="2099-12-31",
)

field_content_type = ContentType.objects.get_for_model(ScanReportField)
self.recommendation = MappingRecommendation.objects.create(
content_type=field_content_type,
object_id=self.field.id,
concept=self.concept,
score=0.85,
tool_name="test-tool",
tool_version="1.0.0",
)
self.scan_report_concept = ScanReportConcept.objects.create(
content_type=field_content_type,
object_id=self.field.id,
concept=self.concept,
)

def test_scan_report_Fields_v3_serializer_includes_recommendations(self):
"""Test that ScanReportFieldListSerializerV3 includes recommendations ."""
serializer = ScanReportFieldListSerializerV3(self.field)
data = serializer.data

# Check that mapping recommendations are included
self.assertIn("mapping_recommendations", data)
self.assertEqual(len(data["mapping_recommendations"]), 1)

recommendation_data = data["mapping_recommendations"][0]
self.assertEqual(recommendation_data["id"], self.recommendation.id)
self.assertEqual(recommendation_data["score"], 0.85)
self.assertEqual(recommendation_data["tool_name"], "test-tool")
self.assertEqual(recommendation_data["tool_version"], "1.0.0")
self.assertEqual(recommendation_data["concept"]["concept_id"], 12345)
self.assertEqual(recommendation_data["concept"]["concept_name"], "Test Concept")

def test_scan_report_Fields_v3_serializer_includes_concepts(self):
"""Test that ScanReportFieldListSerializerV3 includes concepts."""
serializer = ScanReportFieldListSerializerV3(self.field)
data = serializer.data

# check that concepts are included
self.assertIn("concepts", data)
self.assertEqual(len(data["concepts"]), 1)

concept_data = data["concepts"][0]
self.assertEqual(concept_data["id"], self.scan_report_concept.id)
self.assertEqual(concept_data["concept"]["concept_id"], 12345)
self.assertEqual(concept_data["concept"]["concept_name"], "Test Concept")
135 changes: 135 additions & 0 deletions app/api/test/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,3 +871,138 @@ def test_scan_report_value_list_v3_includes_recommendations(self):
self.assertEqual(
recommendation_data["concept"]["concept_name"], concept.concept_name
)


class TestScanReportFieldListViewset(TestCase):
def setUp(self):
# Set up Data Partner
self.data_partner = DataPartner.objects.create(name="Silvan Elves")

# Set up datasets
self.public_dataset = Dataset.objects.create(
name="The Shire",
visibility=VisibilityChoices.PUBLIC,
data_partner=self.data_partner,
)

self.scan_report = ScanReport.objects.create(
dataset="Test Dataset",
visibility=VisibilityChoices.PUBLIC,
parent_dataset=self.public_dataset,
)

self.table = ScanReportTable.objects.create(
scan_report=self.scan_report,
name="Test Table",
)

self.field = ScanReportField.objects.create(
scan_report_table=self.table,
name="Test Field",
description_column="Test Description",
type_column="string",
)

self.concept = Concept.objects.create(
concept_id=12345,
concept_name="Test Concept",
concept_code="TEST123",
domain_id="Test",
vocabulary_id="Test",
concept_class_id="Test",
standard_concept="S",
valid_start_date="2020-01-01",
valid_end_date="2099-12-31",
)

field_content_type = ContentType.objects.get_for_model(ScanReportField)
self.recommendation = MappingRecommendation.objects.create(
content_type=field_content_type,
object_id=self.field.id,
concept=self.concept,
score=0.85,
tool_name="test-tool",
tool_version="1.0.0",
)
self.scan_report_concept = ScanReportConcept.objects.create(
content_type=field_content_type,
object_id=self.field.id,
concept=self.concept,
)

self.client = APIClient()
self.url = (
f"/api/v3/scanreports/{self.scan_report.id}/tables/{self.table.id}/fields/"
)

@mock.patch.dict(os.environ, {"AZ_FUNCTION_USER": "az_functions"})
def test_scan_report_field_list_v3_includes_recommendations(self):
"""Test that ScanReportFieldListV3 includes mapping recommendations."""

response = self.client.get(self.url)
# Verify response
self.assertEqual(response.status_code, 200)
data = response.json()

# Check that the field is returned
self.assertEqual(len(data["results"]), 1)
field_data = data["results"][0]

# Check that mapping recommendations are included
self.assertIn("mapping_recommendations", field_data)
self.assertEqual(len(field_data["mapping_recommendations"]), 1)

recommendation_data = field_data["mapping_recommendations"][0]
self.assertEqual(recommendation_data["id"], self.recommendation.id)
self.assertEqual(recommendation_data["score"], 0.85)
self.assertEqual(recommendation_data["tool_name"], "test-tool")
self.assertEqual(recommendation_data["tool_version"], "1.0.0")
self.assertEqual(
recommendation_data["concept"]["concept_id"], self.concept.concept_id
)
self.assertEqual(
recommendation_data["concept"]["concept_name"], self.concept.concept_name
)

@mock.patch.dict(os.environ, {"AZ_FUNCTION_USER": "az_functions"})
def test_scan_report_field_list_v3_includes_concepts(self):
"""Test that ScanReportFieldListV3 includes concepts."""

response = self.client.get(self.url)
# Verify response
self.assertEqual(response.status_code, 200)
data = response.json()

# Check that the field is returned
self.assertEqual(len(data["results"]), 1)
field_data = data["results"][0]
# Check that concepts are included
self.assertIn("concepts", field_data)
self.assertEqual(len(field_data["concepts"]), 1)

concept_data = field_data["concepts"][0]
self.assertEqual(concept_data["id"], self.scan_report_concept.id)
self.assertEqual(concept_data["concept"]["concept_id"], 12345)
self.assertEqual(concept_data["concept"]["concept_name"], "Test Concept")
self.assertEqual(concept_data["concept"]["concept_code"], "TEST123")

@mock.patch.dict(os.environ, {"AZ_FUNCTION_USER": "az_functions"})
def test_scan_report_field_list_v3_filter_has_concepts_false(self):
"""Test that ?has_concepts=false returns only fields with no concepts."""
# Add a second field with no concepts attached
unmapped_field = ScanReportField.objects.create(
scan_report_table=self.table,
name="Unmapped Field",
description_column="No concepts here",
type_column="string",
)

response = self.client.get(f"{self.url}?has_concepts=false")

self.assertEqual(response.status_code, 200)
data = response.json()

# Only the unmapped field should be returned; the mapped one from setUp
# has a ScanReportConcept attached and should be filtered out.
self.assertEqual(len(data["results"]), 1)
self.assertEqual(data["results"][0]["id"], unmapped_field.id)
Loading
Loading