From 97e262781ecb40eb9d963e7d9e99ceefae4ef190 Mon Sep 17 00:00:00 2001 From: sambles Date: Tue, 3 Dec 2019 13:42:44 +0000 Subject: [PATCH] Feature/260 json_file_handlers (#273) Add JSON endpoints for settings * Create JOSN to File handlers * Add openapi setting schemas * Working - json to file * Add JSON `settings` endpoints to models and analyses + validation * Fix filewrite issue * Adjust unittest + add dependencies * generic model settings * Fix external schema ref's in swagger * Fix missing link re-names * Add unittests for new endpoints, show all validation errors * Keep default model_settings file for the moment * Rename ModelResource -> ModelSettings --- model_resource.json | 64 +---- requirements.in | 1 + requirements.txt | 1 + src/server/oasisapi/analyses/models.py | 3 + src/server/oasisapi/analyses/serializers.py | 7 + .../analyses/tests/test_analysis_api.py | 138 +++++++++++ src/server/oasisapi/analyses/viewsets.py | 37 ++- src/server/oasisapi/analysis_models/models.py | 2 + .../oasisapi/analysis_models/serializers.py | 8 +- .../tests/test_analysis_model.py | 164 ++++++++++++- .../oasisapi/analysis_models/viewsets.py | 28 ++- src/server/oasisapi/auth/views.py | 8 +- src/server/oasisapi/data_files/viewsets.py | 2 +- src/server/oasisapi/files/serializers.py | 3 +- src/server/oasisapi/files/views.py | 57 ++++- src/server/oasisapi/healthcheck/views.py | 2 +- src/server/oasisapi/portfolios/serializers.py | 3 +- src/server/oasisapi/portfolios/viewsets.py | 2 +- src/server/oasisapi/schemas.py | 120 --------- src/server/oasisapi/schemas/__init__.py | 0 .../oasisapi/schemas/analysis_settings.json | 230 ++++++++++++++++++ src/server/oasisapi/schemas/custom_swagger.py | 42 ++++ .../oasisapi/schemas/model_settings.json | 194 +++++++++++++++ src/server/oasisapi/schemas/serializers.py | 185 ++++++++++++++ src/server/oasisapi/urls.py | 25 +- 25 files changed, 1118 insertions(+), 208 deletions(-) delete mode 100644 src/server/oasisapi/schemas.py create mode 100644 src/server/oasisapi/schemas/__init__.py create mode 100644 src/server/oasisapi/schemas/analysis_settings.json create mode 100644 src/server/oasisapi/schemas/custom_swagger.py create mode 100644 src/server/oasisapi/schemas/model_settings.json create mode 100644 src/server/oasisapi/schemas/serializers.py diff --git a/model_resource.json b/model_resource.json index 6282b9354..f59af2fec 100644 --- a/model_resource.json +++ b/model_resource.json @@ -1,69 +1,13 @@ { "model_settings":[ - {"event_set":{ - "name": "Event Set", - "desc": "Either Probablistic or Historic", - "type":"dictionary", - "default": "P", - - "values":{ - "P": "Proabilistic", - "H": "Historic" - } - } - }, - { - "event_occurrence_id":{ - "name": "Occurrence Set", - "desc": "Tooltip for Occurrence selection", - "type":"dictionary", - "default": "1", - "values":{ - "1":"Long Term", - "2":"Near Term WSST", - "3":"Historic" - } - } - - }, - { - "peril_wind":{ - "name": "Wind Peril", - "desc": "Run model with Wind Peril", - "type":"boolean", - "default": true - - } - }, - { - "peril_surge":{ - "name": "Surge Peril", - "desc": "Run model with Surge Peril", - "type":"boolean", - "default": false - - } - }, - { - "leakage_factor": { - "name": "Leakage Factor", - "desc": "Tooltip for Leakage option", - "type": "float", - "default": 0.5, - "min": 0.0, - "max":1.0 - } - } + {} ], "lookup_settings":[ { - "PerilCodes":{ - "type":"dictionary", - "values":{ - "WW1": "Windstorm with storm surge", - "WW2": "Windstorm w/o storm surge" + "PerilCodes":{ + "type":"dictionary", + "values":{"AA1": "All perils"} } - } } ] } diff --git a/requirements.in b/requirements.in index 97542924d..407b4acdb 100755 --- a/requirements.in +++ b/requirements.in @@ -2,6 +2,7 @@ oasislmf celery pymysql jsonpickle +jsonschema requests pyopenssl fasteners diff --git a/requirements.txt b/requirements.txt index c878ecd29..b4603a586 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,6 +53,7 @@ itypes==1.1.0 # via coreapi jinja2-time==0.2.0 # via cookiecutter jinja2==2.10.1 # via cookiecutter, coreschema, jinja2-time jsonpickle==1.2 +jsonschema==2.6.0 kombu==4.6.4 # via celery markdown==3.1.1 markupsafe==1.1.1 # via jinja2 diff --git a/src/server/oasisapi/analyses/models.py b/src/server/oasisapi/analyses/models.py index 1d4af6491..c4b4429dc 100644 --- a/src/server/oasisapi/analyses/models.py +++ b/src/server/oasisapi/analyses/models.py @@ -87,6 +87,9 @@ def get_absolute_copy_url(self, request=None): def get_absolute_settings_file_url(self, request=None): return reverse('analysis-settings-file', kwargs={'version': 'v1', 'pk': self.pk}, request=request) + def get_absolute_settings_url(self, request=None): + return reverse('analysis-settings', kwargs={'version': 'v1', 'pk': self.pk}, request=request) + def get_absolute_input_file_url(self, request=None): return reverse('analysis-input-file', kwargs={'version': 'v1', 'pk': self.pk}, request=request) diff --git a/src/server/oasisapi/analyses/serializers.py b/src/server/oasisapi/analyses/serializers.py index 7ad6b05c3..2f892f41c 100644 --- a/src/server/oasisapi/analyses/serializers.py +++ b/src/server/oasisapi/analyses/serializers.py @@ -7,6 +7,7 @@ class AnalysisSerializer(serializers.ModelSerializer): input_file = serializers.SerializerMethodField() settings_file = serializers.SerializerMethodField() + settings = serializers.SerializerMethodField() lookup_errors_file = serializers.SerializerMethodField() lookup_success_file = serializers.SerializerMethodField() lookup_validation_file = serializers.SerializerMethodField() @@ -30,6 +31,7 @@ class Meta: 'complex_model_data_files', 'input_file', 'settings_file', + 'settings', 'lookup_errors_file', 'lookup_success_file', 'lookup_validation_file', @@ -49,6 +51,11 @@ def get_settings_file(self, instance): request = self.context.get('request') return instance.get_absolute_settings_file_url(request=request) if instance.settings_file else None + @swagger_serializer_method(serializer_or_field=serializers.URLField) + def get_settings(self, instance): + request = self.context.get('request') + return instance.get_absolute_settings_url(request=request) if instance.settings_file else None + @swagger_serializer_method(serializer_or_field=serializers.URLField) def get_lookup_errors_file(self, instance): request = self.context.get('request') diff --git a/src/server/oasisapi/analyses/tests/test_analysis_api.py b/src/server/oasisapi/analyses/tests/test_analysis_api.py index 1e290fd5b..8877856a0 100644 --- a/src/server/oasisapi/analyses/tests/test_analysis_api.py +++ b/src/server/oasisapi/analyses/tests/test_analysis_api.py @@ -126,6 +126,7 @@ def test_cleaned_name_portfolio_and_model_are_present___object_is_created(self, 'portfolio': portfolio.pk, 'model': model.pk, 'settings_file': response.request.application_url + analysis.get_absolute_settings_file_url(), + 'settings': response.request.application_url + analysis.get_absolute_settings_url(), 'input_file': response.request.application_url + analysis.get_absolute_input_file_url(), 'lookup_errors_file': response.request.application_url + analysis.get_absolute_lookup_errors_file_url(), 'lookup_success_file': response.request.application_url + analysis.get_absolute_lookup_success_file_url(), @@ -181,6 +182,7 @@ def test_complex_model_file_present___object_is_created(self, name): 'portfolio': portfolio.pk, 'model': model.pk, 'settings_file': None, + 'settings': None, 'input_file': None, 'lookup_errors_file': None, 'lookup_success_file': None, @@ -680,6 +682,142 @@ def test_output_file_is_cleared(self): self.assertIsNone(Analysis.objects.get(pk=response.json['id']).output_file) +class AnalysisSettingsJson(WebTestMixin, TestCase): + def test_user_is_not_authenticated___response_is_forbidden(self): + analysis = fake_analysis() + + response = self.app.get(analysis.get_absolute_settings_url(), expect_errors=True) + self.assertIn(response.status_code, [401,403]) + + def test_settings_json_is_not_present___get_response_is_404(self): + user = fake_user() + analysis = fake_analysis() + + response = self.app.get( + analysis.get_absolute_settings_url(), + headers={ + 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) + }, + expect_errors=True, + ) + + self.assertEqual(404, response.status_code) + + def test_settings_json_is_not_present___delete_response_is_404(self): + user = fake_user() + analysis = fake_analysis() + + response = self.app.delete( + analysis.get_absolute_settings_url(), + headers={ + 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) + }, + expect_errors=True, + ) + + self.assertEqual(404, response.status_code) + + def test_settings_json_is_not_valid___response_is_400(self): + with TemporaryDirectory() as d: + with override_settings(MEDIA_ROOT=d): + user = fake_user() + analysis = fake_analysis() + json_data = { + "analysis_settings": { + "analysis_tag": "test_analysis", + "module_supplier_id": "OasisIM", + "model_version_id": "1", + "number_of_samples": 0, + "gul_threshold": 0, + "model_settings": { + "use_random_number_file": True, + "event_occurrence_file_id": 1 + }, + "gul_output": True, + "gul_summaries": [ + { + "id": 1, + "summarycalc": True, + "eltcalc": True, + "aalcalc": "Not-A-Boolean", + "pltcalc": True, + "lec_output":False + } + ], + "il_output": False + } + } + + response = self.app.post( + analysis.get_absolute_settings_url(), + headers={ + 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) + }, + params=json.dumps(json_data), + content_type='application/json', + expect_errors=True, + ) + + validation_error = { + 'number_of_samples': '0 is less than the minimum of 1', + 'gul_summaries-0-aalcalc': "'Not-A-Boolean' is not of type 'boolean'", + 'required': "'source_tag' is a required property" + } + self.assertEqual(400, response.status_code) + self.assertEqual(json.loads(response.body), validation_error) + + + def test_settings_json_is_uploaded___can_be_retrieved(self): + with TemporaryDirectory() as d: + with override_settings(MEDIA_ROOT=d): + user = fake_user() + analysis = fake_analysis() + json_data = { + "analysis_settings": { + "source_tag": "test_source", + "analysis_tag": "test_analysis", + "module_supplier_id": "OasisIM", + "model_version_id": "1", + "number_of_samples": 10, + "gul_threshold": 0, + "model_settings": { + "use_random_number_file": True, + "event_occurrence_file_id": 1 + }, + "gul_output": True, + "gul_summaries": [ + { + "id": 1, + "summarycalc": True, + "eltcalc": True, + "aalcalc": True, + "pltcalc": True, + "lec_output":False + } + ], + "il_output": False + } + } + + self.app.post( + analysis.get_absolute_settings_url(), + headers={ + 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) + }, + params=json.dumps(json_data), + content_type='application/json' + ) + + response = self.app.get( + analysis.get_absolute_settings_url(), + headers={ + 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) + }, + ) + self.assertEqual(json.loads(response.body), json_data['analysis_settings']) + self.assertEqual(response.content_type, 'application/json') + + class AnalysisSettingsFile(WebTestMixin, TestCase): def test_user_is_not_authenticated___response_is_forbidden(self): analysis = fake_analysis() diff --git a/src/server/oasisapi/analyses/viewsets.py b/src/server/oasisapi/analyses/viewsets.py index b7b1cb5b0..7edfdce41 100644 --- a/src/server/oasisapi/analyses/viewsets.py +++ b/src/server/oasisapi/analyses/viewsets.py @@ -10,15 +10,16 @@ from drf_yasg.utils import swagger_auto_schema from django_filters import rest_framework as filters -from ..analysis_models.models import AnalysisModel -from ..filters import TimeStampedFilter, CsvMultipleChoiceFilter, CsvModelMultipleChoiceFilter -from ..files.views import handle_related_file -from ..files.serializers import RelatedFileSerializer from .models import Analysis from .serializers import AnalysisSerializer, AnalysisCopySerializer -from ..schemas import FILE_RESPONSE +from ..analysis_models.models import AnalysisModel from ..data_files.serializers import DataFileSerializer +from ..filters import TimeStampedFilter, CsvMultipleChoiceFilter, CsvModelMultipleChoiceFilter +from ..files.views import handle_related_file, handle_json_data +from ..files.serializers import RelatedFileSerializer +from ..schemas.custom_swagger import FILE_RESPONSE +from ..schemas.serializers import AnalysisSettingsSerializer class AnalysisFilter(TimeStampedFilter): @@ -364,3 +365,29 @@ def data_files(self, request, pk=None, version=None): df_serializer = DataFileSerializer(df, many=True, context=context) return Response(df_serializer.data) + + +class AnalysisSettingsView(viewsets.ModelViewSet): + """ + list: + Return the settings of an Analysis object. + """ + queryset = Analysis.objects.all() + serializer_class = AnalysisSerializer + filter_class = AnalysisFilter + + @swagger_auto_schema(methods=['get'], responses={200: AnalysisSettingsSerializer}) + @swagger_auto_schema(methods=['post'], request_body=AnalysisSettingsSerializer, responses={201: RelatedFileSerializer}) + @action(methods=['get', 'post', 'delete'], detail=True) + def analysis_settings(self, request, pk=None, version=None): + """ + get: + Gets the analyses `settings` contents + + post: + Sets the analyses `settings` contents + + delete: + Disassociates the portfolios `settings_file` contents + """ + return handle_json_data(self.get_object(), 'settings_file', request, AnalysisSettingsSerializer) diff --git a/src/server/oasisapi/analysis_models/models.py b/src/server/oasisapi/analysis_models/models.py index 2fadbee4c..e37adae86 100644 --- a/src/server/oasisapi/analysis_models/models.py +++ b/src/server/oasisapi/analysis_models/models.py @@ -30,3 +30,5 @@ def queue_name(self): def get_absolute_resources_file_url(self, request=None): return reverse('analysis-model-resource-file', kwargs={'version': 'v1', 'pk': self.pk}, request=request) + def get_absolute_settings_url(self, request=None): + return reverse('model-settings', kwargs={'version': 'v1', 'pk': self.pk}, request=request) diff --git a/src/server/oasisapi/analysis_models/serializers.py b/src/server/oasisapi/analysis_models/serializers.py index 3f466988e..2925127d6 100644 --- a/src/server/oasisapi/analysis_models/serializers.py +++ b/src/server/oasisapi/analysis_models/serializers.py @@ -1,11 +1,12 @@ from drf_yasg.utils import swagger_serializer_method +from django.core.files import File from rest_framework import serializers from .models import AnalysisModel - class AnalysisModelSerializer(serializers.ModelSerializer): resource_file = serializers.SerializerMethodField() + settings = serializers.SerializerMethodField() class Meta: model = AnalysisModel @@ -18,6 +19,7 @@ class Meta: 'modified', 'data_files', 'resource_file', + 'settings', ) def create(self, validated_data): @@ -30,3 +32,7 @@ def create(self, validated_data): def get_resource_file(self, instance): request = self.context.get('request') return instance.get_absolute_resources_file_url(request=request) + @swagger_serializer_method(serializer_or_field=serializers.URLField) + def get_settings(self, instance): + request = self.context.get('request') + return instance.get_absolute_settings_url(request=request) diff --git a/src/server/oasisapi/analysis_models/tests/test_analysis_model.py b/src/server/oasisapi/analysis_models/tests/test_analysis_model.py index 4fae8665e..6350869cb 100644 --- a/src/server/oasisapi/analysis_models/tests/test_analysis_model.py +++ b/src/server/oasisapi/analysis_models/tests/test_analysis_model.py @@ -1,8 +1,10 @@ import json import string +from backports.tempfile import TemporaryDirectory +from django.test import override_settings from django.urls import reverse -from django_webtest import WebTest +from django_webtest import WebTest, WebTestMixin from hypothesis import given, settings from hypothesis.extra.django import TestCase from hypothesis.strategies import text @@ -11,6 +13,8 @@ from ...auth.tests.fakes import fake_user from ..models import AnalysisModel +from .fakes import fake_analysis_model + # Override default deadline for all tests to 8s settings.register_profile("ci", deadline=800.0) settings.load_profile("ci") @@ -90,3 +94,161 @@ def test_data_is_valid___object_is_created(self, supplier_id, model_id, version_ self.assertEqual(model.supplier_id, supplier_id) self.assertEqual(model.version_id, version_id) self.assertEqual(model.model_id, model_id) + + + +class ModelSettingsJson(WebTestMixin, TestCase): + def test_user_is_not_authenticated___response_is_forbidden(self): + models = fake_analysis_model() + + response = self.app.get(models.get_absolute_settings_url(), expect_errors=True) + self.assertIn(response.status_code, [401,403]) + + + """ Add these check back in once models auto-update their settings fields + """ +# def test_settings_json_is_not_present___get_response_is_404(self): +# user = fake_user() +# models = fake_analysis_model() +# +# response = self.app.get( +# models.get_absolute_settings_url(), +# headers={ +# 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) +# }, +# expect_errors=True, +# ) +# +# self.assertEqual(404, response.status_code) +# +# def test_settings_json_is_not_present___delete_response_is_404(self): +# user = fake_user() +# models = fake_analysis_model() +# +# response = self.app.delete( +# models.get_absolute_settings_url(), +# headers={ +# 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) +# }, +# expect_errors=True, +# ) +# +# self.assertEqual(404, response.status_code) + + def test_settings_json_is_not_valid___response_is_400(self): + with TemporaryDirectory() as d: + with override_settings(MEDIA_ROOT=d): + user = fake_user() + models = fake_analysis_model() + json_data = { + "model_settings":[ + { + "event_set":{ + "name": "Event Set", + "desc": "Either Probablistic or Historic", + "type": "boolean", + "default": "P", + "values":{ + "P": "Proabilistic", + "H": "Historic" + } + } + }, + ], + "Invalid_section": 'Null', + "lookup_settings":[ + { + "PerilCodes":{ + "type":"dictionary", + "values":{ + "WSSS": "Single Peril: Storm Surge", + "WTC": 1, + } + } + } + ] + } + + response = self.app.post( + models.get_absolute_settings_url(), + headers={ + 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) + }, + params=json.dumps(json_data), + content_type='application/json', + expect_errors=True, + ) + + validation_error = { + 'model_settings-0-event_set-type': "'boolean' is not one of ['dictionary']", + 'lookup_settings-0-PerilCodes-values-WTC': "1 is not of type 'string'", + 'lookup_settings-0-PerilCodes-values': "'WSSS' does not match any of the regexes: '^[a-zA-Z0-9]{3}$'" + } + self.assertEqual(400, response.status_code) + self.assertEqual(json.loads(response.body), validation_error) + + + def test_settings_json_is_uploaded___can_be_retrieved(self): + with TemporaryDirectory() as d: + with override_settings(MEDIA_ROOT=d): + user = fake_user() + models = fake_analysis_model() + json_data = { + "model_settings":[ + { + "event_set":{ + "name": "Event Set", + "desc": "Either Probablistic or Historic", + "type":"dictionary", + "default": "P", + "values":{ + "P": "Proabilistic", + "H": "Historic" + } + } + }, + { + "event_occurrence_id":{ + "name": "Occurrence Set", + "desc": "PiWind Occurrence selection", + "type":"dictionary", + "default": "1", + "values":{ + "1":"Long Term" + } + } + + } + ], + "lookup_settings":[ + { + "PerilCodes":{ + "type":"dictionary", + "values":{ + "WSS": "Single Peril: Storm Surge", + "WTC": "Single Peril: Tropical Cyclone", + "WW1": "Group Peril: Windstorm with storm surge", + "WW2": "Group Peril: Windstorm w/o storm surge" + } + } + } + ] + } + + self.app.post( + models.get_absolute_settings_url(), + headers={ + 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) + }, + params=json.dumps(json_data), + content_type='application/json' + ) + + response = self.app.get( + models.get_absolute_settings_url(), + headers={ + 'Authorization': 'Bearer {}'.format(AccessToken.for_user(user)) + }, + ) + self.assertEqual(json.loads(response.body), json_data) + self.assertEqual(response.content_type, 'application/json') diff --git a/src/server/oasisapi/analysis_models/viewsets.py b/src/server/oasisapi/analysis_models/viewsets.py index 5b773b94a..d11b0af3a 100644 --- a/src/server/oasisapi/analysis_models/viewsets.py +++ b/src/server/oasisapi/analysis_models/viewsets.py @@ -15,14 +15,15 @@ from rest_framework.response import Response from rest_framework.settings import api_settings -from ..filters import TimeStampedFilter -from ..files.views import handle_related_file -from ..files.serializers import RelatedFileSerializer from .models import AnalysisModel -from ..schemas import FILE_RESPONSE from .serializers import AnalysisModelSerializer from ..data_files.serializers import DataFileSerializer +from ..filters import TimeStampedFilter +from ..files.views import handle_related_file, handle_json_data +from ..files.serializers import RelatedFileSerializer +from ..schemas.custom_swagger import FILE_RESPONSE +from ..schemas.serializers import ModelSettingsSerializer class AnalysisModelFilter(TimeStampedFilter): @@ -127,7 +128,7 @@ def parser_classes(self): else: return api_settings.DEFAULT_PARSER_CLASSES - @swagger_auto_schema(methods=['get'], responses={200: FILE_RESPONSE}) + @swagger_auto_schema(methods=['get', 'post'], responses={200: FILE_RESPONSE}) @action(methods=['get', 'post', 'delete'], detail=True) def resource_file(self, request, pk=None, version=None): """ @@ -158,3 +159,20 @@ def data_files(self, request, pk=None, version=None): df_serializer = DataFileSerializer(df, many=True, context=context) return Response(df_serializer.data) + + +class ModelSettingsView(viewsets.ModelViewSet): + queryset = AnalysisModel.objects.all() + serializer_class = AnalysisModelSerializer + filter_class = AnalysisModelFilter + + @swagger_auto_schema(method='get', responses={200: ModelSettingsSerializer}) + @swagger_auto_schema(method='post', request_body=ModelSettingsSerializer, responses={201: RelatedFileSerializer}) + @action(methods=['get', 'post', 'delete'], detail=True) + def model_settings(self, request, pk=None, version=None): + try: + return handle_json_data(self.get_object(), 'resource_file', request, ModelSettingsSerializer) + except Http404: + with io.open(os.path.join(settings.STATIC_ROOT, 'model_resource.json')) as default_resource: + data = json.load(default_resource) + return Response(data) diff --git a/src/server/oasisapi/auth/views.py b/src/server/oasisapi/auth/views.py index d9d42f66b..5fbf99ee6 100644 --- a/src/server/oasisapi/auth/views.py +++ b/src/server/oasisapi/auth/views.py @@ -5,14 +5,10 @@ TokenObtainPairView as BaseTokenObtainPairView from .serializers import TokenRefreshSerializer, TokenObtainPairSerializer -from ..schemas import ( - TokenObtainPairResponseSerializer, - TokenRefreshResponseSerializer, - TOKEN_REFRESH_HEADER, -) +from ..schemas.serializers import TokenObtainPairResponseSerializer, TokenRefreshResponseSerializer +from ..schemas.custom_swagger import TOKEN_REFRESH_HEADER -# TODO: add header auth params to swagger class TokenRefreshView(BaseTokenRefreshView): """ Fetches a new authentication token from your refresh token. diff --git a/src/server/oasisapi/data_files/viewsets.py b/src/server/oasisapi/data_files/viewsets.py index 62952714f..c29adb747 100644 --- a/src/server/oasisapi/data_files/viewsets.py +++ b/src/server/oasisapi/data_files/viewsets.py @@ -10,7 +10,7 @@ from ..files.views import handle_related_file from ..filters import TimeStampedFilter from .models import DataFile -from ..schemas import FILE_RESPONSE +from ..schemas.custom_swagger import FILE_RESPONSE from .serializers import DataFileSerializer diff --git a/src/server/oasisapi/files/serializers.py b/src/server/oasisapi/files/serializers.py index c6e2da271..4ab3ab8ef 100644 --- a/src/server/oasisapi/files/serializers.py +++ b/src/server/oasisapi/files/serializers.py @@ -40,9 +40,8 @@ def __init__(self, *args, content_types=None, **kwargs): def validate(self, attrs): attrs['creator'] = self.context['request'].user attrs['content_type'] = attrs['file'].content_type - attrs['filename'] = self.context['request'].FILES['file'] + attrs['filename'] = attrs['file'].name # attrs['filehash_md5'] = md5_filehash(self.context['request'].FILES['file']) - return super(RelatedFileSerializer, self).validate(attrs) def validate_file(self, value): diff --git a/src/server/oasisapi/files/views.py b/src/server/oasisapi/files/views.py index e346bae75..718f8aa29 100644 --- a/src/server/oasisapi/files/views.py +++ b/src/server/oasisapi/files/views.py @@ -1,4 +1,7 @@ -from django.http import StreamingHttpResponse, Http404 +import json + +from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile +from django.http import StreamingHttpResponse, Http404, QueryDict from rest_framework.response import Response from .serializers import RelatedFileSerializer @@ -39,7 +42,6 @@ def _handle_post_related_file(parent, field, request, content_types): serializer.is_valid(raise_exception=True) instance = serializer.create(serializer.validated_data) setattr(parent, field, instance) - parent.save() return Response(RelatedFileSerializer(instance=instance, content_types=content_types).data) @@ -54,6 +56,46 @@ def _handle_delete_related_file(parent, field): return Response() +def _json_write_to_file(parent, field, request, serializer): + json_serializer = serializer() + data = json_serializer.validate(request.data) + + # create file object + with open(json_serializer.filenmame, 'w+') as f: + in_memory_file = UploadedFile( + file=f, + name=json_serializer.filenmame, + content_type='application/json', + size=len(data.encode('utf-8')), + charset=None + ) + + # wrap and re-open file + file_obj = QueryDict('', mutable=True) + file_obj.update({'file': in_memory_file}) + file_obj['file'].open() + file_obj['file'].seek(0) + file_obj['file'].write(data) + serializer = RelatedFileSerializer( + data=file_obj, + content_types='application/json', + context={'request': request} + ) + + serializer.is_valid(raise_exception=True) + instance = serializer.create(serializer.validated_data) + setattr(parent, field, instance) + parent.save() + return Response(RelatedFileSerializer(instance=instance, content_types='application/json').data) + + +def _json_read_from_file(parent, field): + f = getattr(parent, field) + if not f: + raise Http404() + else: + return Response(json.load(f)) + def handle_related_file(parent, field, request, content_types): method = request.method.lower() @@ -63,3 +105,14 @@ def handle_related_file(parent, field, request, content_types): return _handle_post_related_file(parent, field, request, content_types) elif method == 'delete': return _handle_delete_related_file(parent, field) + + +def handle_json_data(parent, field, request, serializer): + method = request.method.lower() + + if method == 'get': + return _json_read_from_file(parent, field) + elif method == 'post': + return _json_write_to_file(parent, field, request, serializer) + elif method == 'delete': + return _handle_delete_related_file(parent, field) diff --git a/src/server/oasisapi/healthcheck/views.py b/src/server/oasisapi/healthcheck/views.py index 1e1231586..35e92d4ed 100644 --- a/src/server/oasisapi/healthcheck/views.py +++ b/src/server/oasisapi/healthcheck/views.py @@ -1,7 +1,7 @@ from drf_yasg.utils import swagger_auto_schema from rest_framework import views from rest_framework.response import Response -from ..schemas import HEALTHCHECK +from ..schemas.custom_swagger import HEALTHCHECK class HealthcheckView(views.APIView): diff --git a/src/server/oasisapi/portfolios/serializers.py b/src/server/oasisapi/portfolios/serializers.py index e8b68faec..a12d40fd0 100644 --- a/src/server/oasisapi/portfolios/serializers.py +++ b/src/server/oasisapi/portfolios/serializers.py @@ -4,7 +4,8 @@ from ..analyses.serializers import AnalysisSerializer from .models import Portfolio -from ..schemas import ( + +from ..schemas.serializers import ( LocFileSerializer, AccFileSerializer, ReinsInfoFileSerializer, diff --git a/src/server/oasisapi/portfolios/viewsets.py b/src/server/oasisapi/portfolios/viewsets.py index b205b3ce3..7bad99853 100644 --- a/src/server/oasisapi/portfolios/viewsets.py +++ b/src/server/oasisapi/portfolios/viewsets.py @@ -15,7 +15,7 @@ from ..files.views import handle_related_file from ..files.serializers import RelatedFileSerializer from .models import Portfolio -from ..schemas import FILE_RESPONSE +from ..schemas.custom_swagger import FILE_RESPONSE from .serializers import PortfolioSerializer, CreateAnalysisSerializer diff --git a/src/server/oasisapi/schemas.py b/src/server/oasisapi/schemas.py deleted file mode 100644 index b2560dfc3..000000000 --- a/src/server/oasisapi/schemas.py +++ /dev/null @@ -1,120 +0,0 @@ -__all__ = [ - 'FILE_RESPONSE', - 'HEALTHCHECK', - 'TOKEN_REFRESH_HEADER', - 'LocFileSerializer', - 'AccFileSerializer', - 'ReinsInfoFileSerializer', - 'ReinsScopeFileSerializer', -] - -from drf_yasg import openapi -from drf_yasg.openapi import Schema - -from rest_framework import serializers - - -FILE_RESPONSE = openapi.Response( - 'File Download', - schema=Schema(type=openapi.TYPE_FILE), - headers={ - "Content-Disposition": { - "description": "filename", - "type": openapi.TYPE_STRING, - "default": 'attachment; filename=""' - }, - "Content-Type": { - "description": "mime type", - "type": openapi.TYPE_STRING - }, - - }) - -HEALTHCHECK = Schema( - title='HealthCheck', - type='object', - properties={ - "status": Schema(title='status', read_only=True, type='string', enum=['OK']) - } -) - -TOKEN_REFRESH_HEADER = openapi.Parameter( - 'authorization', - 'header', - description="Refresh Token", - type='string', - default='Bearer ' -) - - -class TokenObtainPairResponseSerializer(serializers.Serializer): - refresh_token = serializers.CharField(read_only=True) - access_token = serializers.CharField(read_only=True) - token_type = serializers.CharField(read_only=True, default="Bearer") - expires_in = serializers.IntegerField(read_only=True, default=86400) - - def create(self, validated_data): - raise NotImplementedError() - - def update(self, instance, validated_data): - raise NotImplementedError() - - -class TokenRefreshResponseSerializer(serializers.Serializer): - access_token = serializers.CharField() - token_type = serializers.CharField() - expires_in = serializers.IntegerField() - - def create(self, validated_data): - raise NotImplementedError() - - def update(self, instance, validated_data): - raise NotImplementedError() - - -class LocFileSerializer(serializers.Serializer): - url = serializers.URLField() - name = serializers.CharField() - Stored = serializers.CharField() - - def create(self, validated_data): - raise NotImplementedError() - - def update(self, instance, validated_data): - raise NotImplementedError() - - -class AccFileSerializer(serializers.Serializer): - url = serializers.URLField() - name = serializers.CharField() - Stored = serializers.CharField() - - def create(self, validated_data): - raise NotImplementedError() - - def update(self, instance, validated_data): - raise NotImplementedError() - - -class ReinsInfoFileSerializer(serializers.Serializer): - url = serializers.URLField() - name = serializers.CharField() - Stored = serializers.CharField() - - def create(self, validated_data): - raise NotImplementedError() - - def update(self, instance, validated_data): - raise NotImplementedError() - - -class ReinsScopeFileSerializer(serializers.Serializer): - url = serializers.URLField() - name = serializers.CharField() - Stored = serializers.CharField() - - def create(self, validated_data): - raise NotImplementedError() - - def update(self, instance, validated_data): - raise NotImplementedError() diff --git a/src/server/oasisapi/schemas/__init__.py b/src/server/oasisapi/schemas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/server/oasisapi/schemas/analysis_settings.json b/src/server/oasisapi/schemas/analysis_settings.json new file mode 100644 index 000000000..f490dd6cb --- /dev/null +++ b/src/server/oasisapi/schemas/analysis_settings.json @@ -0,0 +1,230 @@ +{ + "$schema": "http://oasislmf.org/analysis_settings/draft/schema#", + "type": "object", + "title": "Analysis settings.", + "description": "Specifies the model settings and outputs for an analysis.", + "definitions": { + "output_summaries": { + "type": "array", + "uniqueItems": false, + "title": "Insured loss summary outputs", + "description": "Specified which outputs should be generated for which summary sets, for insured losses.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "multipleOf": 1, + "title": "Summary ID", + "description": "Identifier for the summary set." + }, + "oed_fields": { + "type": "array", + "items": { + "type": "string" + }, + "title": "OED fields", + "description": "A list of OED fields used to group the generated losses." + }, + "summarycalc": { + "type": "boolean", + "title": "Summary calculation flag", + "description": "If true, output summary calculations by level for the summary set.", + "default": false + }, + "eltcalc": { + "type": "boolean", + "title": "ELT calculation flag", + "description": "If true, output the event loss table by level for the summary set.", + "default": false + }, + "aalcalc": { + "type": "boolean", + "title": "AAL calculation flag", + "description": "If true, output the average annual loss by level for the summary set.", + "default": false + }, + "pltcalc": { + "type": "boolean", + "title": "PLT calculation flag", + "description": "If true, output the period loss table by level for the summary set.", + "default": false + }, + "lec_output": { + "type": "boolean", + "title": "LEC calculation flag", + "description": "If true, output loss exceed curves by level for the summary set.", + "default": false + }, + "leccalc": { + "type": "object", + "title": "LEC calculation settings", + "description": "Specifies which loss exceedence curve types will be outputed for the summary level", + "properties": { + "return_period_file": { + "type": "boolean", + "title": "Return period file flag", + "description": "If true, a file listing the return periods will be provided as part of the analysis inputs. If false, a default set will be used.", + "default": false + }, + "full_uncertainty_aep": { + "type": "boolean", + "title": "Full uncertainty AEP flag", + "description": "If true and LEC output is true, output the full uncertainty aggregate loss exceedence curve by level for the summary set.", + "default": true + }, + "full_uncertainty_oep": { + "type": "boolean", + "title": "Full uncertainty OEP flag", + "description": "If true and LEC output is true, output the full uncertainty occurrence loss exceedence curve by level for the summary set.", + "default": false + }, + "wheatsheaf_aep": { + "type": "boolean", + "title": "Wheatsheaf AEP flag", + "description": "If true and LEC output is true, output the wheatsheaf aggregate loss exceedence curve by level for the summary set.", + "default": false + }, + "wheatsheaf_oep": { + "type": "boolean", + "title": "Wheatsheaf OEP flag", + "description": "If true and LEC output is true, output the wheatsheaf occurrence loss exceedence curve by level for the summary set.", + "default": false + }, + "wheatsheaf_mean_aep": { + "type": "boolean", + "title": "Wheatsheaf mean AEP flag", + "description": "If true and LEC output is true, output the wheatsheaf mean aggregate loss exceedence curve by level for the summary set.", + "default": false + }, + "wheatsheaf_mean_oep": { + "type": "boolean", + "title": "Wheatsheaf mean OEP schema.", + "description": "If true and LEC output is true, output the wheatsheaf occurrence loss exceedence curve by level for the summary set.", + "default": false + }, + "sample_mean_aep": { + "type": "boolean", + "title": "Sample mean AEP flag", + "description": "If true and LEC output is true, output the sample mean aggregate loss exceedence curve by level for the summary set.", + "default": false + }, + "sample_mean_oep": { + "type": "boolean", + "title": "Sample mean OEP schema.", + "description": "If true and LEC output is true, output the sample occurrence loss exceedence curve by level for the summary set.", + "default": false + } + } + } + }, + "required": [ + "id" + ] + } + } + }, + + "properties": { + "source_tag": { + "type": ["integer","string"], + "minLength": 1, + "title": "Source Tag", + "description": "Labels the origin of the analysis." + }, + "analysis_tag": { + "type": ["integer","string"], + "minLength": 1, + "title": "Analysis Tag", + "description": "Labels the analysis with an identifier." + }, + "module_supplier_id": { + "type": "string", + "title": "Module supplier ID", + "description": "Identifier for the model vendor/module supplier." + }, + "model_version_id": { + "type": "string", + "title": "Model version ID", + "description": "Identifier for the model and version." + }, + "number_of_samples": { + "type": "integer", + "minimum": 1, + "title": "Number of samples.", + "description": "The number of samples generated per event.", + "default": 100 + }, + "gul_threshold": { + "type": "number", + "minimum": 0, + "title": "Ground-up loss threshold", + "description": "The threshold at which groun-up losses will be capped.", + "default": 0 + }, + "model_settings": { + "type": "object", + "title": "Model settings", + "description": "Model specific settings.", + "properties": { + "use_random_number_file": { + "type": "boolean", + "title": "Use random number file", + "description": "If true use a pre-generated set of random number, if false generate random numbers dynamically." + }, + "event_occurrence_file_id": { + "type": "integer", + "title": "Event occurrence file ID.", + "description": "Identifier for the event occurrence file that is used for output calculations.", + "default": 1 + } + } + }, + "gul_output": { + "type": "boolean", + "title": "Produce GUL output", + "description": "If true generate ground-up loss outputs as per specified gul-summaries.", + "default": false + }, + "gul_summaries": { + "title": "Ground-up loss summary outputs", + "description": "Specified which outputs should be generated for which summary sets, for ground-up losses.", + "$ref": "#/definitions/output_summaries" + }, + "il_output": { + "type": "boolean", + "title": "Produce il output", + "description": "If true generate insured loss outputs as per specified il-summaries.", + "default": false + }, + "il_summaries": { + "title": "Insured loss summary outputs", + "description": "Specified which outputs should be generated for which summary sets, for insured losses.", + "$ref": "#/definitions/output_summaries" + }, + "ri_output": { + "type": "boolean", + "title": "Produce ri output", + "description": "If true generate reinsurance net loss outputs as per specified ri-summaries.", + "default": false + }, + "ri_summaries": { + "type": "array", + "title": "Reinsurance net loss summary outputs", + "description": "Specified which outputs should be generated for which summary sets, for reinsurance net losses.", + "$ref": "#/definitions/output_summaries" + } + + }, + "required": [ + "source_tag", + "analysis_tag", + "module_supplier_id", + "model_version_id", + "number_of_samples", + "gul_threshold", + "model_settings", + "gul_output", + "gul_summaries" + ] +} diff --git a/src/server/oasisapi/schemas/custom_swagger.py b/src/server/oasisapi/schemas/custom_swagger.py new file mode 100644 index 000000000..2dc8737bc --- /dev/null +++ b/src/server/oasisapi/schemas/custom_swagger.py @@ -0,0 +1,42 @@ +__all__ = [ + 'FILE_RESPONSE', + 'HEALTHCHECK', + 'TOKEN_REFRESH_HEADER', +] + +from drf_yasg import openapi +from drf_yasg.openapi import Schema + + + +FILE_RESPONSE = openapi.Response( + 'File Download', + schema=Schema(type=openapi.TYPE_FILE), + headers={ + "Content-Disposition": { + "description": "filename", + "type": openapi.TYPE_STRING, + "default": 'attachment; filename=""' + }, + "Content-Type": { + "description": "mime type", + "type": openapi.TYPE_STRING + }, + + }) + +HEALTHCHECK = Schema( + title='HealthCheck', + type='object', + properties={ + "status": Schema(title='status', read_only=True, type='string', enum=['OK']) + } +) + +TOKEN_REFRESH_HEADER = openapi.Parameter( + 'authorization', + 'header', + description="Refresh Token", + type='string', + default='Bearer ' +) diff --git a/src/server/oasisapi/schemas/model_settings.json b/src/server/oasisapi/schemas/model_settings.json new file mode 100644 index 000000000..c991eb5d8 --- /dev/null +++ b/src/server/oasisapi/schemas/model_settings.json @@ -0,0 +1,194 @@ +{ + "$schema": "http://oasislmf.org/model_resource/draft/schema#", + "type": "object", + "title": "Model resource settings", + "description": "Specifies the model resource schema", + "definitions": { + "model_option_dictionary": { + "type": "object", + "uniqueItems": false, + "title": "Dictionary option", + "description": "Selection options from dictionary", + "properties":{ + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "UI tooltip", + "description": "UI description for selection" + }, + "type": { + "type": "string", + "enum": ["dictionary"], + "title": "UI dictionary type", + "description": "Create UI dropdown widget" + }, + "default":{ + "type": "string", + "title": "Initial value", + "description": "Default key to select from dictionary 'values'" + }, + "values":{ + "type": "object", + "title": "Selection options", + "description": "Key value pairs to present in UI", + "patternProperties": { + "^[a-zA-Z0-9]*$": {"type": "string"} + }, + "additionalProperties": false, + "minProperties": 1 + } + }, + "required": ["name", "desc", "type", "default", "values"] + }, + "model_option_float": { + "type": "object", + "uniqueItems": false, + "title": "Float option", + "description": "Select float value", + "properties":{ + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "UI tooltip", + "description": "UI description for selection" + }, + "type": { + "type": "string", + "enum": ["float"], + "title": "UI float type", + "description": "Create UI slider widget" + }, + "default":{ + "type": "number", + "title": "Initial value", + "description": "Default 'value' set for float variable" + }, + "max":{ + "type": "number", + "title": "Maximum value", + "description": "Maximum Value for float variable" + }, + "min":{ + "type": "number", + "title": "Minimum value", + "description": "Minimum Value for float variable" + } + }, + "required": ["name", "desc", "type", "default", "max", "min"] + }, + "model_option_boolean": { + "type": "object", + "uniqueItems": false, + "title": "Boolean option", + "description": "Select boolean value", + "properties":{ + "name": { + "type": "string", + "title": "UI Option", + "description": "UI name for selection", + "minLength": 1 + }, + "desc": { + "type": "string", + "title": "UI tooltip", + "description": "UI description for selection" + }, + "type": { + "type": "string", + "enum": ["boolean"], + "title": "UI boolean type", + "description": "Create UI checkbox widget" + }, + "default":{ + "type": "boolean", + "title": "Initial value", + "description": "Default 'value' set for variable" + } + }, + "required": ["name", "desc", "type", "default"] + }, + "lookup_supported_perils": { + "type": "object", + "uniqueItems": false, + "title": "Supported Perils", + "description": "List of all OED peril codes support by this model", + "properties":{ + "type": { + "type": "string", + "enum": ["dictionary"], + "title": "UI dictionary type", + "description": "Create UI dropdown widget" + }, + "values":{ + "type": "object", + "title": "Selection options", + "description": "Key value pairs to present in UI", + "patternProperties": { + "^[a-zA-Z0-9]{3}$": {"type": "string"} + }, + "additionalProperties": false, + "minProperties": 1 + } + }, + "required": ["type", "values"] + } + }, + "properties": { + "model_settings":{ + "type": "array", + "uniqueItems": false, + "title": "Model setting options", + "description": "Runtime settings available to a model", + "items": { + "type": "object", + "properties": { + "event_set":{ + "title": "Event set selector", + "description": "The 'key' from values is used as a file suffix' events_.bin", + "$ref": "#/definitions/model_option_dictionary" + }, + "event_occurrence_id":{ + "title": "Occurrence set selector", + "description": "The 'key' from values is used as a file suffix' occurrence_.bin", + "$ref": "#/definitions/model_option_dictionary" + } + }, + "patternProperties": { + "^[a-zA-Z0-9]*$": { + "anyOf": [ + {"$ref": "#/definitions/model_option_float"}, + {"$ref": "#/definitions/model_option_boolean"} + ] + } + } + } + }, + "lookup_settings":{ + "type": "array", + "uniqueItems": false, + "title": "Model Lookup options", + "description": "Model lookup section", + "items": { + "type": "object", + "properties": { + "PerilCodes":{ + "title": "Lookup Peril codes", + "description": "Display a list of Valid OED peril codes", + "$ref": "#/definitions/lookup_supported_perils" + } + } + } + } + }, + "required": ["model_settings", "lookup_settings"] +} diff --git a/src/server/oasisapi/schemas/serializers.py b/src/server/oasisapi/schemas/serializers.py new file mode 100644 index 000000000..963c8d9b8 --- /dev/null +++ b/src/server/oasisapi/schemas/serializers.py @@ -0,0 +1,185 @@ +__all__ = [ + 'LocFileSerializer', + 'AccFileSerializer', + 'ReinsInfoFileSerializer', + 'ReinsScopeFileSerializer', + 'AnalysisSettingsSerializer', + 'ModelSettingsSerializer', +] + +import io +import os +import json + +from rest_framework import serializers + +import jsonschema +from jsonschema.exceptions import ValidationError as JSONSchemaValidationError +from jsonschema.exceptions import SchemaError as JSONSchemaError + +class TokenObtainPairResponseSerializer(serializers.Serializer): + refresh_token = serializers.CharField(read_only=True) + access_token = serializers.CharField(read_only=True) + token_type = serializers.CharField(read_only=True, default="Bearer") + expires_in = serializers.IntegerField(read_only=True, default=86400) + + def create(self, validated_data): + raise NotImplementedError() + + def update(self, instance, validated_data): + raise NotImplementedError() + + +class TokenRefreshResponseSerializer(serializers.Serializer): + access_token = serializers.CharField() + token_type = serializers.CharField() + expires_in = serializers.IntegerField() + + def create(self, validated_data): + raise NotImplementedError() + + def update(self, instance, validated_data): + raise NotImplementedError() + + +class LocFileSerializer(serializers.Serializer): + url = serializers.URLField() + name = serializers.CharField() + Stored = serializers.CharField() + + def create(self, validated_data): + raise NotImplementedError() + + def update(self, instance, validated_data): + raise NotImplementedError() + + +class AccFileSerializer(serializers.Serializer): + url = serializers.URLField() + name = serializers.CharField() + Stored = serializers.CharField() + + def create(self, validated_data): + raise NotImplementedError() + + def update(self, instance, validated_data): + raise NotImplementedError() + + +class ReinsInfoFileSerializer(serializers.Serializer): + url = serializers.URLField() + name = serializers.CharField() + Stored = serializers.CharField() + + def create(self, validated_data): + raise NotImplementedError() + + def update(self, instance, validated_data): + raise NotImplementedError() + + +class ReinsScopeFileSerializer(serializers.Serializer): + url = serializers.URLField() + name = serializers.CharField() + Stored = serializers.CharField() + + def create(self, validated_data): + raise NotImplementedError() + + def update(self, instance, validated_data): + raise NotImplementedError() + +def update_links(link_prefix, d): + """ + Linking in pre-defined scheams with path links will be nested + into the overall swagger schema, breaking preset links + + Remap based on 'link_prefix' value + '#definitions/option' -> #definitions/SWAGGER_OBJECT/definitions/option + + """ + for k,v in d.items(): + if isinstance(v, dict): + update_links(link_prefix, v) + elif isinstance(v, list): + for el in v: + if isinstance(el, dict): + update_links(link_prefix, el) + else: + if k in '$ref': + link = v.split('#')[-1] + d[k] = "{}{}".format(link_prefix, link) + +def load_json_schema(json_schema_file, link_prefix=None): + """ + Load json schema stored in the .schema dir + """ + schema_dir = os.path.dirname(os.path.abspath(__file__)) + schema_fp = os.path.join(schema_dir, json_schema_file) + with io.open(schema_fp, 'r', encoding='utf-8') as f: + schema = json.load(f) + if link_prefix: + update_links(link_prefix, schema) + return schema + + +class JsonSettingsSerializer(serializers.Serializer): + + def to_internal_value(self, data): + return data + + def validate_json(self, data): + try: + validator = jsonschema.Draft4Validator(self.schema) + validation_errors = [e for e in validator.iter_errors(data)] + + # Iteratre over all errors and raise as single exception + if validation_errors: + exception_msgs = {} + for err in validation_errors: + if err.path: + field = '-'.join([str(e) for e in err.path]) + elif err.schema_path: + field = '-'.join([str(e) for e in err.schema_path]) + else: + field = 'error' + exception_msgs[field] = err.message + raise serializers.ValidationError(exception_msgs) + + except (JSONSchemaValidationError, JSONSchemaError) as e: + raise serializers.ValidationError(e.message) + return self.to_internal_value(json.dumps(data)) + + +class ModelSettingsSerializer(JsonSettingsSerializer): + class Meta: + swagger_schema_fields = load_json_schema( + json_schema_file='model_settings.json', + link_prefix='#/definitions/ModelSettings' + ) + + def __init__(self, *args, **kwargs): + super(ModelSettingsSerializer, self).__init__(*args, **kwargs) + self.filenmame = 'model_settings.json' + self.schema = load_json_schema('model_settings.json') + + def validate(self, data): + return super(ModelSettingsSerializer, self).validate_json(data) + + +class AnalysisSettingsSerializer(JsonSettingsSerializer): + class Meta: + swagger_schema_fields = load_json_schema( + json_schema_file='analysis_settings.json', + link_prefix='#/definitions/AnalysisSettings' + ) + + def __init__(self, *args, **kwargs): + super(AnalysisSettingsSerializer, self).__init__(*args, **kwargs) + self.filenmame = 'analysis_settings.json' + self.schema = load_json_schema('analysis_settings.json') + + def validate(self, data): + if 'analysis_settings' in data: + data = data['analysis_settings'] + return super(AnalysisSettingsSerializer, self).validate_json(data) diff --git a/src/server/oasisapi/urls.py b/src/server/oasisapi/urls.py index e72bde18e..e33ea6449 100644 --- a/src/server/oasisapi/urls.py +++ b/src/server/oasisapi/urls.py @@ -6,9 +6,9 @@ from drf_yasg.views import get_schema_view from rest_framework import routers, permissions -from .analysis_models.viewsets import AnalysisModelViewSet +from .analysis_models.viewsets import AnalysisModelViewSet, ModelSettingsView +from .analyses.viewsets import AnalysisViewSet, AnalysisSettingsView from .portfolios.viewsets import PortfolioViewSet -from .analyses.viewsets import AnalysisViewSet from .healthcheck.views import HealthcheckView from .data_files.viewsets import DataFileViewset from .oed_info.views import PerilcodesView @@ -51,8 +51,29 @@ permission_classes=(permissions.AllowAny,), ) +""" Developer note: + +These are custom routes to use the endpoint 'settings' +adding the method 'def settings( .. )' fails under +viewsets.ModelViewSet due to it overriding +the internal Django settings object +""" + +model_settings = ModelSettingsView.as_view({ + 'get': 'model_settings', + 'post': 'model_settings', + 'delete': 'model_settings' +}) +analyses_settings = AnalysisSettingsView.as_view({ + 'get': 'analysis_settings', + 'post': 'analysis_settings', + 'delete': 'analysis_settings' +}) + urlpatterns = [ + url(r'^(?P[^/]+)/models/(?P\d+)/settings/', model_settings, name='model-settings'), + url(r'^(?P[^/]+)/analyses/(?P\d+)/settings/', analyses_settings, name='analysis-settings'), url(r'^(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), url(r'^$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-ui'), url(r'^', include('src.server.oasisapi.auth.urls', namespace='auth')),