Skip to content

Commit

Permalink
epiv2 CRUD api (#820)
Browse files Browse the repository at this point in the history
* implement epiv2 api

* CRUD api endpoints for design, exposure measurements, chemicals, exposure_levels,
  outcomes, adjustment factors, data extraction

* test cases for all of the above

* a new ArrayedFlexibleChoiceField (plus tests), for FlexibleChoiceField-style behavior
  when the underlying field is wrapped in an ArrayField.

* new BelongsToSameDesign mixin to validate that related objects belong to same overall design,
  e.g. when associating a chemical with an exposure level.

* add environment variable (SKIP_BMDS_TESTS=1) to disable bmds testing.

* refactoring / etc. epiv2 API based on code review feedback

* more consistent path naming

* refactoring/renaming/cleanup of the SameDesignSerializer mixin

* fixing black formatting issues

* minor renaming / reworking of epiv2 coming out of code review feedback

* more consistent class names

* rework the SameDesign serializer a bit more

* etc.

* rename

* skip if in ci or flag is set

* final updates from view

---------

Co-authored-by: Tom Feiler <tom.feiler@icf.com>
Co-authored-by: Andy Shapiro <shapiromatron@gmail.com>
  • Loading branch information
3 people authored May 19, 2023
1 parent 5075e0f commit fa2dffa
Show file tree
Hide file tree
Showing 8 changed files with 1,361 additions and 64 deletions.
54 changes: 54 additions & 0 deletions hawc/apps/common/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from pydantic import BaseModel
from pydantic import ValidationError as PydanticError
from rest_framework import serializers
Expand Down Expand Up @@ -162,6 +163,59 @@ def to_internal_value(self, data):
self.fail("invalid_choice", input=data)


class FlexibleChoiceArrayField(serializers.ChoiceField):
"""
like FlexibleChoiceField; accepts either the raw choice value OR a case-insensitive
display value when supplying data, intended for choice fields wrapped in an ArrayField
"""

# dupe invalid_choice in ChoiceField, and create a new one that we'll use
default_error_messages = {
"invalid_choice": _('"{input}" is not a valid choice.'),
"full_custom": _("{input}"),
}

def to_representation(self, obj):
if len(obj) == 0 and self.allow_blank:
return obj
return [self._choices[x] for x in obj]

def to_internal_value(self, data):
if (data is None or len(data) == 0) and self.allow_blank:
return []
else:
converted_values = []
invalid_values = []
for x in data:
element_handled = False
# Look for an exact match of either key or val
for key, val in self._choices.items():
if key == x or val == x:
converted_values.append(key)
element_handled = True
break

if not element_handled:
# No exact match; if a string was passed in let's try case-insensitive value match
if type(x) is str:
key = get_id_from_choices(self._choices.items(), x)
if key is not None:
converted_values.append(key)
element_handled = True

if not element_handled:
invalid_values.append(x)

if len(invalid_values) == 0:
return converted_values
else:
invalid_summary = ", ".join([f"'{x}'" for x in invalid_values])
self.fail(
"full_custom",
input=f"input {data} contained invalid value(s): {invalid_summary}.",
)


class FlexibleDBLinkedChoiceField(FlexibleChoiceField):
"""
FlexibleChoiceField subclass which derives its choices from a model/database table and optionally supports multiples.
Expand Down
46 changes: 44 additions & 2 deletions hawc/apps/epiv2/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def export(self, request, pk):
return Response(exporter.build_export())


class Design(EditPermissionsCheckMixin, AssessmentEditViewSet):
class DesignViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet):
edit_check_keys = ["study"]
assessment_filter_args = "study__assessment"
model = models.Design
Expand All @@ -44,6 +44,48 @@ def get_queryset(self, *args, **kwargs):
return self.model.objects.all()


class Metadata(viewsets.ViewSet):
class MetadataViewSet(viewsets.ViewSet):
def list(self, request):
return EpiV2Metadata.handle_request(request)


class ChemicalViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet):
edit_check_keys = ["design"]
assessment_filter_args = "design__study__assessment"
model = models.Chemical
serializer_class = serializers.ChemicalSerializer


class ExposureViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet):
edit_check_keys = ["design"]
assessment_filter_args = "design__study__assessment"
model = models.Exposure
serializer_class = serializers.ExposureSerializer


class ExposureLevelViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet):
edit_check_keys = ["design"]
assessment_filter_args = "design__study__assessment"
model = models.ExposureLevel
serializer_class = serializers.ExposureLevelSerializer


class OutcomeViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet):
edit_check_keys = ["design"]
assessment_filter_args = "design__study__assessment"
model = models.Outcome
serializer_class = serializers.OutcomeSerializer


class AdjustmentFactorViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet):
edit_check_keys = ["design"]
assessment_filter_args = "design__study__assessment"
model = models.AdjustmentFactor
serializer_class = serializers.AdjustmentFactorSerializer


class DataExtractionViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet):
edit_check_keys = ["design"]
assessment_filter_args = "design__study__assessment"
model = models.DataExtraction
serializer_class = serializers.DataExtractionSerializer
69 changes: 69 additions & 0 deletions hawc/apps/epiv2/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from django.db import models
from rest_framework import serializers

from .models import Design


class SameDesignSerializerMixin:
"""
ex: when updating an ExposureLevel, there's a related Chemical and Exposure sometimes passed into the payload as
"chemical_id" and "exposure_level_id". Basic DRF functionality verifies that the supplied values are valid
items in the database, but it's a common requirement that they also belong to the same parent object; in this
case, the same design. This mixin performs that type of validation logic.
Could potentially generalize this even further (so that it's not just useful for "Design"); this type of
relationship is not uncommon.
"""

same_design_fields: list[tuple[str, type[models.Model]]]

def _get_contextual_design(self):
# figure out what design we're concerned with...
design = None

if "design" in self.initial_data:
# load from the supplied payload...
design_id = self.initial_data.get("design")
try:
design = Design.objects.get(id=design_id)
except Design.DoesNotExist:
design = None
elif self.instance is not None:
# load from the previously saved state
design = self.instance.design

# this should never happen -- default validation should prevent it if a bad design id is
# passed in initial_data. But just in case:
if design is None:
raise serializers.ValidationError({"design": "could not determine contextual design"})

return design

def validate(self, data):
validated_data = super().validate(data)

# first off - were any of the parameters we're concerned with in the payload? If not, there's
# no need to perform any custom validation.
perform_custom_validation = any(
[field_name in self.initial_data for field_name, _ in self.same_design_fields]
)

if perform_custom_validation is False:
return validated_data
else:
design = self._get_contextual_design()

invalid_fields = {}
for param_name, model_klass in self.same_design_fields:
if param_name in self.initial_data:
candidate_id = self.initial_data.get(param_name)
candidate_obj = model_klass.objects.get(id=candidate_id)

if candidate_obj.design.id != design.id:
invalid_fields[
param_name
] = f"object with id={candidate_id} does not belong to the correct design."
if invalid_fields:
raise serializers.ValidationError(invalid_fields)

return validated_data
Loading

0 comments on commit fa2dffa

Please sign in to comment.