diff --git a/api/applications/tests/test_finalise_application.py b/api/applications/tests/test_finalise_application.py index 30a5ca95a6..3b48a96292 100644 --- a/api/applications/tests/test_finalise_application.py +++ b/api/applications/tests/test_finalise_application.py @@ -1,6 +1,7 @@ import pytest from datetime import datetime +from django.db.utils import IntegrityError from django.urls import reverse from django.utils import timezone from parameterized import parameterized @@ -791,3 +792,17 @@ def test_approve_quantity_greater_than_applied_for_failure(self): response_data, {"errors": {f"quantity-{self.good_on_application.id}": [strings.Licence.INVALID_QUANTITY_ERROR]}}, ) + + def test_goods_unique_on_licence(self): + + licence = StandardLicenceFactory(case=self.standard_application, status=LicenceStatus.DRAFT) + + with pytest.raises(IntegrityError) as e: + GoodOnLicence.objects.create( + good=self.good_on_application, licence=licence, usage=0.0, quantity=5.0, value=100.00 + ) + GoodOnLicence.objects.create( + good=self.good_on_application, licence=licence, usage=0.0, quantity=5.0, value=100.00 + ) + + self.assertIn(f"({str(licence.id)}, {str(self.good_on_application.id)}) already exists", e.value.args[0]) diff --git a/api/conf/settings.py b/api/conf/settings.py index 581d9011e5..d5d3a4cca4 100644 --- a/api/conf/settings.py +++ b/api/conf/settings.py @@ -124,8 +124,12 @@ "api.external_data", "api.support", "health_check", + "health_check.cache", "health_check.contrib.celery", "health_check.contrib.celery_ping", + "health_check.db", + "health_check.contrib.migrations", + "health_check.storage", "django_audit_log_middleware", "lite_routing", "api.appeals", @@ -137,14 +141,6 @@ "drf_spectacular", ] -if not IS_ENV_DBT_PLATFORM: - INSTALLED_APPS += [ - "health_check.db", - "health_check.cache", - "health_check.storage", - "health_check.contrib.migrations", - ] - MOCK_VIRUS_SCAN_ACTIVATE_ENDPOINTS = env("MOCK_VIRUS_SCAN_ACTIVATE_ENDPOINTS") if MOCK_VIRUS_SCAN_ACTIVATE_ENDPOINTS: diff --git a/api/conf/settings_test.py b/api/conf/settings_test.py index 204b5d34b1..fcaa9933c8 100644 --- a/api/conf/settings_test.py +++ b/api/conf/settings_test.py @@ -20,3 +20,9 @@ DB_ANONYMISER_AWS_REGION = "eu-west-2" DB_ANONYMISER_AWS_STORAGE_BUCKET_NAME = "anonymiser-bucket" DB_ANONYMISER_AWS_ENDPOINT_URL = None + +try: + INSTALLED_APPS.remove("silk") + MIDDLEWARE.remove("silk.middleware.SilkyMiddleware") +except ValueError: + pass diff --git a/api/conf/urls.py b/api/conf/urls.py index 653ea20614..5651471a1e 100644 --- a/api/conf/urls.py +++ b/api/conf/urls.py @@ -5,11 +5,12 @@ from django.conf import settings import api.core.views -from api.healthcheck.views import HealthCheckPingdomView +from api.healthcheck.views import HealthCheckPingdomView, ServiceAvailableHealthCheckView urlpatterns = [ path("healthcheck/", include("health_check.urls")), path("pingdom/ping.xml", HealthCheckPingdomView.as_view(), name="healthcheck-pingdom"), + path("service-available-check/", ServiceAvailableHealthCheckView.as_view(), name="service-available-check"), path("applications/", include("api.applications.urls")), path("assessments/", include("api.assessments.urls")), path("audit-trail/", include("api.audit_trail.urls")), diff --git a/api/data_workspace/metadata/__init__.py b/api/data_workspace/metadata/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/data_workspace/metadata/routers.py b/api/data_workspace/metadata/routers.py new file mode 100644 index 0000000000..669844cce2 --- /dev/null +++ b/api/data_workspace/metadata/routers.py @@ -0,0 +1,145 @@ +import datetime +import typing + +from rest_framework import serializers +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.routers import DefaultRouter +from rest_framework.views import APIView + +from django.urls import ( + NoReverseMatch, + path, +) + + +class TableMetadataView(APIView): + _ignore_model_permissions = True + schema = None # exclude from schema + metadata = None + + def get(self, request, *args, **kwargs): + tables = [] + namespace = request.resolver_match.namespace + for table_metadata in self.metadata: + url_name = table_metadata["endpoint"] + if namespace: + url_name = f"{namespace}:{url_name}" + try: + url = reverse( + url_name, + args=args, + kwargs=kwargs, + request=request, + ) + except NoReverseMatch: + # Don't bail out if eg. no list routes exist, only detail routes. + continue + + tables.append( + { + "table_name": table_metadata["table_name"], + "endpoint": url, + "indexes": table_metadata["indexes"], + "fields": table_metadata["fields"], + } + ) + return Response({"tables": tables}) + + +def is_optional(field): + return typing.get_origin(field) is typing.Union and type(None) in typing.get_args(field) + + +def get_fields(view): + try: + serializer = view.get_serializer() + except AttributeError: + return [] + + primary_key_field = getattr(view.DataWorkspace, "primary_key", "id") + + fields = [] + for field in serializer.fields.values(): + if isinstance(field, serializers.HiddenField): + continue + + field_metadata = {"name": field.field_name} + if field.field_name == primary_key_field: + field_metadata["primary_key"] = True + + if isinstance(field, serializers.UUIDField): + field_metadata["type"] = "UUID" + if field.allow_null: + field_metadata["nullable"] = True + + elif isinstance(field, serializers.CharField): + field_metadata["type"] = "String" + if field.allow_null: + field_metadata["nullable"] = True + + elif isinstance(field, serializers.SerializerMethodField): + method = getattr(field.parent, field.method_name) + return_type = method.__annotations__["return"] + + if is_optional(return_type): + field_metadata["nullable"] = True + return_type, _ = typing.get_args(return_type) + + if return_type is str: + field_metadata["type"] = "String" + elif return_type is datetime.datetime: + field_metadata["type"] = "DateTime" + else: # pragma: no cover + raise NotImplementedError( + f"Return type of {return_type} for {serializer.__class__.__name__}.{field.method_name} not handled" + ) + + else: # pragma: no cover + raise NotImplementedError(f"Annotation not found for {field}") + + fields.append(field_metadata) + return fields + + +class TableMetadataRouter(DefaultRouter): + def register(self, viewset): + if not hasattr(viewset, "DataWorkspace"): # pragma: no cover + raise NotImplementedError(f"No DataWorkspace configuration found for {viewset}") + + prefix = viewset.DataWorkspace.table_name.replace("_", "-") + basename = f"dw-{prefix}" + + super().register(prefix, viewset, basename) + + def get_metadata_view(self, urls): + metadata = [] + list_name = self.routes[0].name + for _, viewset, basename in self.registry: + data_workspace_metadata = viewset.DataWorkspace + + view = viewset() + view.args = () + view.kwargs = {} + view.format_kwarg = {} + view.request = None + + metadata.append( + { + "table_name": data_workspace_metadata.table_name, + "endpoint": list_name.format(basename=basename), + "indexes": getattr(data_workspace_metadata, "indexes", []), + "fields": getattr(data_workspace_metadata, "fields", get_fields(view)), + } + ) + + return TableMetadataView.as_view(metadata=metadata) + + def get_urls(self): + urls = super().get_urls() + + view = self.get_metadata_view(urls) + metadata_url = path("table-metadata/", view, name="table-metadata") + urls.append(metadata_url) + + return urls diff --git a/api/data_workspace/metadata/tests/__init__.py b/api/data_workspace/metadata/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/data_workspace/metadata/tests/auto_field_urls.py b/api/data_workspace/metadata/tests/auto_field_urls.py new file mode 100644 index 0000000000..205b80b1b9 --- /dev/null +++ b/api/data_workspace/metadata/tests/auto_field_urls.py @@ -0,0 +1,22 @@ +from django.urls import ( + include, + path, +) + +from ..routers import TableMetadataRouter + +from . import views + + +test_router = TableMetadataRouter() + +test_router.register(views.HiddenFieldViewSet) +test_router.register(views.UUIDFieldViewSet) +test_router.register(views.CharFieldViewSet) +test_router.register(views.SerializerMethodFieldViewSet) +test_router.register(views.AutoPrimaryKeyViewSet) +test_router.register(views.ExplicitPrimaryKeyViewSet) + +urlpatterns = [ + path("endpoints/", include(test_router.urls)), +] diff --git a/api/data_workspace/metadata/tests/serializers.py b/api/data_workspace/metadata/tests/serializers.py new file mode 100644 index 0000000000..f3f3498e25 --- /dev/null +++ b/api/data_workspace/metadata/tests/serializers.py @@ -0,0 +1,48 @@ +import datetime + +from typing import Optional + +from rest_framework import serializers + + +class HiddenFieldSerializer(serializers.Serializer): + hidden_field = serializers.HiddenField(default="") + + +class UUIDFieldSerializer(serializers.Serializer): + uuid_field = serializers.UUIDField() + nullable_uuid_field = serializers.UUIDField(allow_null=True) + + +class CharFieldSerializer(serializers.Serializer): + char_field = serializers.CharField() + nullable_char_field = serializers.CharField(allow_null=True) + + +class SerializerMethodFieldSerializer(serializers.Serializer): + returns_string = serializers.SerializerMethodField() + returns_optional_string = serializers.SerializerMethodField() + returns_datetime = serializers.SerializerMethodField() + returns_optional_datetime = serializers.SerializerMethodField() + + def get_returns_string(self, instance) -> str: + return "string" + + def get_returns_optional_string(self, instance) -> Optional[str]: + return None + + def get_returns_datetime(self, instance) -> datetime.datetime: + return datetime.datetime.now() + + def get_returns_optional_datetime(self, instance) -> Optional[datetime.datetime]: + return None + + +class AutoPrimaryKeySerializer(serializers.Serializer): + id = serializers.UUIDField() + not_a_primary_key = serializers.UUIDField() + + +class ExplicitPrimaryKeySerializer(serializers.Serializer): + a_different_id = serializers.UUIDField() + not_a_primary_key = serializers.UUIDField() diff --git a/api/data_workspace/metadata/tests/test_metadata.py b/api/data_workspace/metadata/tests/test_metadata.py new file mode 100644 index 0000000000..1968c4308b --- /dev/null +++ b/api/data_workspace/metadata/tests/test_metadata.py @@ -0,0 +1,172 @@ +from django.urls import ( + include, + path, + reverse, +) + +from rest_framework.test import URLPatternsTestCase + + +class MetadataTestCase(URLPatternsTestCase): + urlpatterns = [ + path("api/", include("api.data_workspace.metadata.tests.urls")), + path("namespaced/", include(("api.data_workspace.metadata.tests.urls", "namespaced"), namespace="namespaced")), + path( + "auto-fields/", + include(("api.data_workspace.metadata.tests.auto_field_urls", "auto-fields"), namespace="auto-fields"), + ), + ] + + def setUp(self): + super().setUp() + + self.url = reverse("table-metadata") + self.namespaced_url = reverse("namespaced:table-metadata") + self.auto_fields_url = reverse("auto-fields:table-metadata") + + def test_metadata_endpoint(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_urls(self): + self.assertEqual( + reverse("dw-fake-table-list"), + "/api/endpoints/fake-table/", + ) + self.assertEqual( + reverse("dw-fake-table-detail", kwargs={"pk": "test"}), + "/api/endpoints/fake-table/test/", + ) + + self.assertEqual( + reverse("namespaced:dw-fake-table-list"), + "/namespaced/endpoints/fake-table/", + ) + self.assertEqual( + reverse("namespaced:dw-fake-table-detail", kwargs={"pk": "test"}), + "/namespaced/endpoints/fake-table/test/", + ) + + def test_metadata_tables_definitions(self): + response = self.client.get(self.url) + output = response.json() + self.assertEqual( + output["tables"], + [ + { + "table_name": "fake_table", + "endpoint": "http://testserver/api/endpoints/fake-table/", + "indexes": [], + "fields": [], + }, + { + "table_name": "another_fake_table", + "endpoint": "http://testserver/api/endpoints/another-fake-table/", + "indexes": ["one", "two", "three"], + "fields": [{"name": "id", "primary_key": True, "type": "UUID"}], + }, + ], + ) + + def test_metadata_tables_definitions_with_namespace(self): + response = self.client.get(self.namespaced_url) + output = response.json() + self.assertEqual( + output["tables"], + [ + { + "table_name": "fake_table", + "endpoint": "http://testserver/namespaced/endpoints/fake-table/", + "indexes": [], + "fields": [], + }, + { + "table_name": "another_fake_table", + "endpoint": "http://testserver/namespaced/endpoints/another-fake-table/", + "indexes": ["one", "two", "three"], + "fields": [{"name": "id", "primary_key": True, "type": "UUID"}], + }, + ], + ) + + def assertFieldsEqual(self, output, table_name, fields): + for table in output["tables"]: + if table["table_name"] == table_name: + self.assertEqual( + table["fields"], + fields, + ) + break + else: + self.fail(f"No table found with name {table_name}") + + def test_hidden_field_definition(self): + response = self.client.get(self.auto_fields_url) + output = response.json() + self.assertFieldsEqual( + output, + "hidden_field", + [], + ) + + def test_uuid_field_definition(self): + response = self.client.get(self.auto_fields_url) + output = response.json() + self.assertFieldsEqual( + output, + "uuid_field", + [ + {"name": "uuid_field", "type": "UUID"}, + {"name": "nullable_uuid_field", "type": "UUID", "nullable": True}, + ], + ) + + def test_char_field_definition(self): + response = self.client.get(self.auto_fields_url) + output = response.json() + self.assertFieldsEqual( + output, + "char_field", + [ + {"name": "char_field", "type": "String"}, + {"name": "nullable_char_field", "type": "String", "nullable": True}, + ], + ) + + def test_serializer_method_field(self): + response = self.client.get(self.auto_fields_url) + output = response.json() + self.assertFieldsEqual( + output, + "serializer_method_field", + [ + {"name": "returns_string", "type": "String"}, + {"name": "returns_optional_string", "type": "String", "nullable": True}, + {"name": "returns_datetime", "type": "DateTime"}, + {"name": "returns_optional_datetime", "type": "DateTime", "nullable": True}, + ], + ) + + def test_auto_primary_key(self): + response = self.client.get(self.auto_fields_url) + output = response.json() + self.assertFieldsEqual( + output, + "auto_primary_key", + [ + {"name": "id", "type": "UUID", "primary_key": True}, + {"name": "not_a_primary_key", "type": "UUID"}, + ], + ) + + def test_explicit_primary_key(self): + response = self.client.get(self.auto_fields_url) + output = response.json() + self.assertFieldsEqual( + output, + "explicit_primary_key", + [ + {"name": "a_different_id", "type": "UUID", "primary_key": True}, + {"name": "not_a_primary_key", "type": "UUID"}, + ], + ) diff --git a/api/data_workspace/metadata/tests/urls.py b/api/data_workspace/metadata/tests/urls.py new file mode 100644 index 0000000000..e48b46e6bf --- /dev/null +++ b/api/data_workspace/metadata/tests/urls.py @@ -0,0 +1,19 @@ +from django.urls import ( + include, + path, +) + +from ..routers import TableMetadataRouter + +from . import views + + +test_router = TableMetadataRouter() + +test_router.register(views.FakeTableViewSet) +test_router.register(views.AnotherFakeTableViewSet) +test_router.register(views.DetailOnlyViewSet) + +urlpatterns = [ + path("endpoints/", include(test_router.urls)), +] diff --git a/api/data_workspace/metadata/tests/views.py b/api/data_workspace/metadata/tests/views.py new file mode 100644 index 0000000000..80e823ec3f --- /dev/null +++ b/api/data_workspace/metadata/tests/views.py @@ -0,0 +1,105 @@ +from rest_framework import viewsets +from rest_framework.response import Response + +from . import serializers + + +class FakeTableViewSet(viewsets.ViewSet): + class DataWorkspace: + table_name = "fake_table" + + def list(self, request): + return Response({}) + + def retrieve(self, request, pk): + return Response({}) + + +class AnotherFakeTableViewSet(viewsets.ViewSet): + class DataWorkspace: + table_name = "another_fake_table" + indexes = ["one", "two", "three"] + fields = [{"name": "id", "primary_key": True, "type": "UUID"}] + + def list(self, request): + return Response({}) + + def retrieve(self, request, pk): + return Response({}) + + +class DetailOnlyViewSet(viewsets.ViewSet): + class DataWorkspace: + table_name = "detail_only_table" + indexes = ["one", "two", "three"] + fields = [{"name": "id", "primary_key": True, "type": "UUID"}] + + def retrieve(self, request, pk): + return Response({}) + + +class HiddenFieldViewSet(viewsets.ViewSet): + class DataWorkspace: + table_name = "hidden_field" + + def get_serializer(self): + return serializers.HiddenFieldSerializer() + + def list(self, request): + return Response({}) + + +class UUIDFieldViewSet(viewsets.ViewSet): + class DataWorkspace: + table_name = "uuid_field" + + def get_serializer(self): + return serializers.UUIDFieldSerializer() + + def list(self, request): + return Response({}) + + +class CharFieldViewSet(viewsets.ViewSet): + class DataWorkspace: + table_name = "char_field" + + def get_serializer(self): + return serializers.CharFieldSerializer() + + def list(self, request): + return Response({}) + + +class SerializerMethodFieldViewSet(viewsets.ViewSet): + class DataWorkspace: + table_name = "serializer_method_field" + + def get_serializer(self): + return serializers.SerializerMethodFieldSerializer() + + def list(self, request): + return Response({}) + + +class AutoPrimaryKeyViewSet(viewsets.ViewSet): + class DataWorkspace: + table_name = "auto_primary_key" + + def get_serializer(self): + return serializers.AutoPrimaryKeySerializer() + + def list(self, request): + return Response({}) + + +class ExplicitPrimaryKeyViewSet(viewsets.ViewSet): + class DataWorkspace: + table_name = "explicit_primary_key" + primary_key = "a_different_id" + + def get_serializer(self): + return serializers.ExplicitPrimaryKeySerializer() + + def list(self, request): + return Response({}) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index d5f48f94cc..ff78b36346 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,7 +1,10 @@ +import datetime + from rest_framework import serializers from api.cases.enums import LicenceDecisionType from api.cases.models import Case +from api.staticdata.countries.models import Country class LicenceDecisionSerializer(serializers.ModelSerializer): @@ -17,10 +20,10 @@ class Meta: "decision_made_at", ) - def get_decision(self, case): + def get_decision(self, case) -> str: return case.decision - def get_decision_made_at(self, case): + def get_decision_made_at(self, case) -> datetime.datetime: if case.decision not in LicenceDecisionType.decisions(): raise ValueError(f"Unknown decision type `{case.decision}`") # pragma: no cover @@ -31,3 +34,11 @@ def get_decision_made_at(self, case): .earliest("created_at") .created_at ) + + +class CountrySerializer(serializers.ModelSerializer): + code = serializers.CharField(source="id") + + class Meta: + model = Country + fields = ("code", "name") diff --git a/api/data_workspace/v2/tests/bdd/scenarios/countries.feature b/api/data_workspace/v2/tests/bdd/scenarios/countries.feature new file mode 100644 index 0000000000..85c9f7adb8 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/scenarios/countries.feature @@ -0,0 +1,23 @@ +@db +Feature: Countries and Territories + +Scenario: Check that the correct country code and name is included in the extract + When I fetch the list of countries + Then the correct country code and name is included in the extract + +Scenario: Check that the correct territory code and name is included in the extract + When I fetch the list of countries + Then the correct territory code and name is included in the extract + +Scenario: A new country appears in the extract when added to countries list + Given I add a new country to the countries list + When I fetch the list of countries + Then the new country appears in the extract + +Scenario: A new country disappears from the extract when removed from countries list + Given I add a new country to the countries list + When I fetch the list of countries + Then the new country appears in the extract + Given I remove the new country from the countries list + When I fetch the list of countries + Then the new country does not appear in the extract diff --git a/api/data_workspace/v2/tests/bdd/test_countries.py b/api/data_workspace/v2/tests/bdd/test_countries.py new file mode 100644 index 0000000000..103deb3e59 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/test_countries.py @@ -0,0 +1,62 @@ +from django.urls import reverse +import pytest +from pytest_bdd import ( + given, + then, + when, + scenarios, +) + +from api.staticdata.countries.models import Country + +scenarios("./scenarios/countries.feature") + + +@pytest.fixture() +def countries_list_url(): + return reverse("data_workspace:v2:dw-countries-list") + + +@pytest.fixture() +def example_country(): + return {"id": "EX", "name": "Example", "is_eu": False} + + +@when("I fetch the list of countries", target_fixture="countries") +def fetch_countries(countries_list_url, unpage_data): + return unpage_data(countries_list_url) + + +@then("the correct country code and name is included in the extract") +def correct_country_code_included_in_extract(countries): + example_country = {"code": "AE", "name": "United Arab Emirates"} + assert example_country in countries + + +@then("the correct territory code and name is included in the extract") +def correct_territory_code_included_in_extract(countries): + example_territory = {"code": "AE-DU", "name": "Dubai"} + assert example_territory in countries + + +@given("I add a new country to the countries list") +def add_new_country_to_countries_list(example_country): + Country.objects.create(**example_country) + + +@then("the new country appears in the extract") +def new_country_appears_in_extract(countries, example_country): + new_country = {"code": example_country["id"], "name": example_country["name"]} + assert new_country in countries + + +@given("I remove the new country from the countries list") +def remove_new_country_from_countries_list(example_country): + new_country = Country.objects.get(id=example_country["id"]) + new_country.delete() + + +@then("the new country does not appear in the extract") +def new_country_does_not_appear_in_extract(countries, example_country): + new_country = {"code": example_country["id"], "name": example_country["name"]} + assert new_country not in countries diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index a8f6055a1a..8b63d581a3 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -1,12 +1,8 @@ -from rest_framework.routers import DefaultRouter - from api.data_workspace.v2 import views +from api.data_workspace.metadata.routers import TableMetadataRouter -router_v2 = DefaultRouter() -router_v2.register( - "licence-decisions", - views.LicenceDecisionViewSet, - basename="dw-licence-decisions", -) +router_v2 = TableMetadataRouter() +router_v2.register(views.LicenceDecisionViewSet) +router_v2.register(views.CountryViewSet) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index ce28e0dd81..5947903af9 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -10,9 +10,11 @@ from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool from api.data_workspace.v2.serializers import ( + CountrySerializer, LicenceDecisionSerializer, LicenceDecisionType, ) +from api.staticdata.countries.models import Country class DisableableLimitOffsetPagination(LimitOffsetPagination): @@ -23,12 +25,18 @@ def paginate_queryset(self, queryset, request, view=None): return super().paginate_queryset(queryset, request, view) -class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): +class BaseViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + + +class LicenceDecisionViewSet(BaseViewSet): serializer_class = LicenceDecisionSerializer + class DataWorkspace: + table_name = "licence_decisions" + def get_queryset(self): queryset = ( ( @@ -55,3 +63,11 @@ def get_queryset(self): .order_by("-reference_code") ) return queryset + + +class CountryViewSet(BaseViewSet): + serializer_class = CountrySerializer + queryset = Country.objects.all().order_by("id", "name") + + class DataWorkspace: + table_name = "countries" diff --git a/api/healthcheck/tests/test_healthcheck.py b/api/healthcheck/tests/test_healthcheck.py index 93bd374e0e..34d31586f9 100644 --- a/api/healthcheck/tests/test_healthcheck.py +++ b/api/healthcheck/tests/test_healthcheck.py @@ -64,3 +64,28 @@ def test_healthcheck_down(client, backends): b"0.23" b"\n" ) + + +""" +The tests below expect a 200 response whether healthchecks produce a healthy +response or not as the url is used by DBT platform pipeline to check that +the django app is alive. +""" + + +def test_service_available_check_broken(client, backends): + backends.reset() + backends.register(HealthCheckBroken) + url = reverse("service-available-check") + response = client.get(url) + + assert response.status_code == 200 + + +def test_service_available_check_ok(client, backends): + backends.reset() + backends.register(HealthCheckOk) + url = reverse("service-available-check") + response = client.get(url) + + assert response.status_code == 200 diff --git a/api/healthcheck/views.py b/api/healthcheck/views.py index dcab1569ed..1fb02a3ed8 100644 --- a/api/healthcheck/views.py +++ b/api/healthcheck/views.py @@ -1,4 +1,6 @@ from health_check.views import MainView +from django.http import HttpResponse +from rest_framework import status class HealthCheckPingdomView(MainView): @@ -8,3 +10,11 @@ def render_to_response(self, context, status): context["errored_plugins"] = [plugin for plugin in context["plugins"] if plugin.errors] context["total_response_time"] = sum([plugin.time_taken for plugin in context["plugins"]]) return super().render_to_response(context=context, status=status, content_type="text/xml") + + +class ServiceAvailableHealthCheckView(MainView): + def get(self, request, *args, **kwargs): + return self.render_to_response() + + def render_to_response(self): + return HttpResponse(status.HTTP_200_OK) diff --git a/api/licences/migrations/0020_alter_goodonlicence_unique_together.py b/api/licences/migrations/0020_alter_goodonlicence_unique_together.py new file mode 100644 index 0000000000..21b0f9496d --- /dev/null +++ b/api/licences/migrations/0020_alter_goodonlicence_unique_together.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-14 12:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("applications", "0084_standardapplication_subject_to_itar_controls"), + ("licences", "0019_auto_20210506_0340"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="goodonlicence", + unique_together={("licence", "good")}, + ), + ] diff --git a/api/licences/models.py b/api/licences/models.py index 02738e5134..cf8e2369cf 100644 --- a/api/licences/models.py +++ b/api/licences/models.py @@ -158,3 +158,6 @@ class GoodOnLicence(TimestampableModel): usage = models.FloatField(null=False, blank=False, default=0) quantity = models.FloatField(null=False, blank=False) value = models.DecimalField(max_digits=15, decimal_places=2, null=False, blank=False) + + class Meta: + unique_together = ("licence", "good")