diff --git a/docker-compose.yml b/docker-compose.yml index 587924e..d660de0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.11" - x-global-environment: &global env_file: - envs/.env # env file - NOT committed to Git diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..fa366d0 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = rcpch_nhs_organisations.settings +python_files = tests.py test_*.py *_tests.py \ No newline at end of file diff --git a/rcpch_nhs_organisations/hospitals/serializers/__init__.py b/rcpch_nhs_organisations/hospitals/serializers/__init__.py index 3bb669f..704531e 100644 --- a/rcpch_nhs_organisations/hospitals/serializers/__init__.py +++ b/rcpch_nhs_organisations/hospitals/serializers/__init__.py @@ -5,6 +5,7 @@ ) from .local_authority_district import ( LocalAuthorityDistrictSerializer, + LocalAuthorityDistrictGeoJSONSerializer, ) from .local_health_board import ( LocalHealthBoardSerializer, diff --git a/rcpch_nhs_organisations/hospitals/serializers/local_authority_district.py b/rcpch_nhs_organisations/hospitals/serializers/local_authority_district.py index 78d6a00..e2131a7 100644 --- a/rcpch_nhs_organisations/hospitals/serializers/local_authority_district.py +++ b/rcpch_nhs_organisations/hospitals/serializers/local_authority_district.py @@ -26,7 +26,7 @@ ) ] ) -class LocalAuthorityDistrictSerializer(GeoFeatureModelSerializer): +class LocalAuthorityDistrictGeoJSONSerializer(GeoFeatureModelSerializer): class Meta: model = LocalAuthorityDistrict geo_field = "geom" @@ -41,3 +41,26 @@ class Meta: "globalid", "geom", ] + + +@extend_schema_serializer( + examples=[ + OpenApiExample( + "/local_authority_district/2024/1/extended", + value={ + "lad24cd": "", + "lad24nm": "", + "lad24nmw": "", + "bng_e": "", + "bng_n": "", + "long": "", + "lat": "", + }, + response_only=True, + ) + ] +) +class LocalAuthorityDistrictSerializer(serializers.ModelSerializer): + class Meta: + model = LocalAuthorityDistrict + fields = ["lad24cd", "lad24nm", "lad24nmw", "bng_e", "bng_n", "long", "lat"] diff --git a/rcpch_nhs_organisations/hospitals/tests/test_viewsets.py b/rcpch_nhs_organisations/hospitals/tests/test_viewsets.py new file mode 100644 index 0000000..5fdd965 --- /dev/null +++ b/rcpch_nhs_organisations/hospitals/tests/test_viewsets.py @@ -0,0 +1,108 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient +from django.contrib.gis.geos import Point, MultiPolygon, Polygon +from django.apps import apps + + +@pytest.fixture +def api_client(): + return APIClient() + + +LocalAuthorityDistrict = apps.get_model("hospitals", "LocalAuthorityDistrict") + + +@pytest.fixture +def local_authority_districts(): + LocalAuthorityDistrict.objects.all().delete() # Clear the table + lad1 = LocalAuthorityDistrict.objects.create( + lad24cd="LAD001", + lad24nm="Test District 1", + lad24nmw="Test District 1 Welsh", + bng_e=350000, + bng_n=400000, + long=-3.0, + lat=53.0, + globalid="globalid1", + geom=MultiPolygon( + Polygon( + ( + (349900, 400100), + (349900, 399900), + (350100, 399900), + (350100, 400100), + (349900, 400100), + ) + ) + ), + ) + lad2 = LocalAuthorityDistrict.objects.create( + lad24cd="LAD002", + lad24nm="Test District 2", + lad24nmw="Test District 2 Welsh", + bng_e=350100, + bng_n=400100, + long=-3.0, + lat=53.0, + globalid="globalid2", + geom=MultiPolygon( + Polygon( + ( + (350000, 400200), + (350000, 400000), + (350200, 400000), + (350200, 400200), + (350000, 400200), + ) + ) + ), + ) + lad3 = LocalAuthorityDistrict.objects.create( + lad24cd="LAD003", + lad24nm="Test District 3", + lad24nmw="Test District 3 Welsh", + bng_e=350200, + bng_n=400200, + long=-3.0, + lat=53.0, + globalid="globalid3", + geom=MultiPolygon( + Polygon( + ( + (350100, 400300), + (350100, 400100), + (350300, 400100), + (350300, 400300), + (350100, 400300), + ) + ) + ), + ) + return [lad1, lad2, lad3] + + +@pytest.mark.django_db +def test_list_local_authority_districts(api_client, local_authority_districts): + url = reverse("local_authority_district-list") + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data["features"]) == 3 + + +@pytest.mark.django_db +def test_within_radius(api_client, local_authority_districts): + url = reverse("local_authority_district-within-radius") + response = api_client.get( + url, {"lat": 53.0, "long": -3.0, "radius": 500000} + ) # within 500km + assert response.status_code == status.HTTP_200_OK + assert len(response.data["features"]) == 3 + + response = api_client.get( + url, {"lat": 53.0, "long": -3.0, "radius": 5} + ) # within 5 km + assert response.status_code == status.HTTP_200_OK + assert len(response.data["features"]) == 0 diff --git a/rcpch_nhs_organisations/hospitals/views/local_authority_district.py b/rcpch_nhs_organisations/hospitals/views/local_authority_district.py index cbb67e8..96b65d4 100644 --- a/rcpch_nhs_organisations/hospitals/views/local_authority_district.py +++ b/rcpch_nhs_organisations/hospitals/views/local_authority_district.py @@ -1,6 +1,7 @@ from rest_framework.response import Response from django.contrib.gis.geos import Point -from django.contrib.gis.db.models.functions import Distance +from django.contrib.gis.db.models.functions import Distance, Transform +from django.contrib.gis.measure import D from rest_framework.decorators import action from rest_framework import ( viewsets, @@ -19,7 +20,10 @@ from drf_spectacular.types import OpenApiTypes from ..models import LocalAuthorityDistrict -from ..serializers import LocalAuthorityDistrictSerializer +from ..serializers import ( + LocalAuthorityDistrictSerializer, + LocalAuthorityDistrictGeoJSONSerializer, +) @extend_schema( @@ -42,7 +46,20 @@ "long": -1.270225, "lat": 54.676159, "globalid": "{F1D3D2A4-1D4D-4D3D-8D3D-3D1D4D3D1D4D}", - "geom": [], + "geom": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-1.270225, 54.676159], + [-1.270225, 54.676159], + [-1.270225, 54.676159], + [-1.270225, 54.676159], + [-1.270225, 54.676159], + ] + ] + ], + }, } ], response_only=True, @@ -50,11 +67,11 @@ ], ), }, - summary="This endpoint returns a list of Local Authority Districts with their boundaries, or an individual authority districts.", + summary="This endpoint returns a list of Local Authority Districts with their boundaries, or an individual local authority district.", ) class LocalAuthorityDistrictViewSet(viewsets.ReadOnlyModelViewSet): """ - This endpoint returns a list of Local Health Boards (Wales) with their boundaries, or an individual local health authority by LAD24CD. + This endpoint returns a list of Local Authority Districts (2024 publication) with their boundaries, or an individual local health authority by LAD24CD. Filter Parameters: @@ -66,14 +83,13 @@ class LocalAuthorityDistrictViewSet(viewsets.ReadOnlyModelViewSet): `long` `lat` `globalid` - `geom` If none are passed, a list is returned. """ queryset = LocalAuthorityDistrict.objects.all().order_by("-lad24nm") - serializer_class = LocalAuthorityDistrictSerializer + serializer_class = LocalAuthorityDistrictGeoJSONSerializer lookup_field = "lad24cd" filterset_fields = [ "lad24cd", @@ -87,6 +103,52 @@ class LocalAuthorityDistrictViewSet(viewsets.ReadOnlyModelViewSet): ] filter_backends = (DjangoFilterBackend,) + def get_serializer_class(self): + if self.action == "within_radius": + return LocalAuthorityDistrictSerializer + return super().get_serializer_class() + + @extend_schema( + parameters=[ + OpenApiParameter( + name="lat", + type=OpenApiTypes.NUMBER, + description="Latitude of the center point", + ), + OpenApiParameter( + name="long", + type=OpenApiTypes.NUMBER, + description="Longitude of the center point", + ), + OpenApiParameter( + name="radius", type=OpenApiTypes.NUMBER, description="Radius in meters" + ), + ], + responses={200: LocalAuthorityDistrictGeoJSONSerializer(many=True)}, + summary="Get Local Authority Districtsand boundaries within a radius", + description="This endpoint returns a list of Local Authority Districts within a specified radius from a given latitude and longitude. It also returns geojson boundaries for each district.", + ) + @action(detail=False, methods=["get"]) + def within_radius_with_geography(self, request): + try: + lat = float(request.query_params.get("lat")) + long = float(request.query_params.get("long")) + radius = float(request.query_params.get("radius")) + except (TypeError, ValueError): + return Response({"error": "Invalid parameters"}, status=400) + + user_location = Point(long, lat, srid=4326) + # the reference system of the geom field is 27700 - transform to 4326 before calculating distance + + queryset = ( + self.queryset.annotate(geom_4326=Transform("geom", 4326)) + .annotate(distance=Distance("geom_4326", user_location)) + .filter(distance__lte=D(m=radius)) # distance is in meters + ) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + @extend_schema( parameters=[ OpenApiParameter( @@ -117,8 +179,16 @@ def within_radius(self, request): return Response({"error": "Invalid parameters"}, status=400) user_location = Point(long, lat, srid=4326) - queryset = self.queryset.annotate( - distance=Distance("geom", user_location) - ).filter(distance__lte=radius) + # the reference system of the geom field is 27700 - transform to 4326 before calculating distance + log_queryset = self.queryset.annotate( + geom_4326=Transform("geom", 4326) + ).annotate(distance=Distance("geom_4326", user_location)) + + queryset = ( + self.queryset.annotate(geom_4326=Transform("geom", 4326)) + .annotate(distance=Distance("geom_4326", user_location)) + .filter(distance__lte=D(m=radius)) # distance is in meters + ) + serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) diff --git a/requirements/development-requirements.txt b/requirements/development-requirements.txt index 450dd6c..24d6595 100644 --- a/requirements/development-requirements.txt +++ b/requirements/development-requirements.txt @@ -5,6 +5,7 @@ autopep8 coverage black +pytest pytest-django # third party imports