Skip to content

Commit

Permalink
refactor assessment permissions in django views and API (#732)
Browse files Browse the repository at this point in the history
* remove custom mixins in favor of AssessmentPermissionsMixin

* wip todo stubs

* Flesh out DRF assessment permission class, fix server breaking errors

* Further seperated assessment api into seperate modules

* Fix bug with OPTIONS requests

* Clean up remaining merge conflicts

* Fix circular import in CI

* Update animal permissions

* Update assessment permissions

* Update bmd permissions

* Update epi permissions

* Update epimeta permissions

* Update epiv2 permissions

* Update invitro permissions

* Updated lit permissions

* Updated mgmt permissions

* Updated rob permissions

* Updated study permissions

* Updated summary permissions

* Fix tests; can_edit_object permissions should apply to reference tag import

* Check object permissions on rob_settings

* Moved DisabledPagination to common app

* Refactoring, added documentation

* fix error

* updates from review

* rename Viewset to ViewSet

* rename a few permission methods

* move methods around

* cleanup

* undo comment

* review views.py

* remove unused methods

* updatesr

* fix bug

---------

Co-authored-by: Daniel Rabstejnek <rabstejnek@gmail.com>
  • Loading branch information
shapiromatron and rabstejnek authored Feb 18, 2023
1 parent ca7ce6a commit efaa660
Show file tree
Hide file tree
Showing 43 changed files with 973 additions and 1,041 deletions.
4 changes: 2 additions & 2 deletions client/hawc_client/riskofbias.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class RiskOfBiasClient(BaseClient):
Client class for risk of bias requests.
"""

def data(self, assessment_id: int) -> pd.DataFrame:
def export(self, assessment_id: int) -> pd.DataFrame:
"""
Retrieves risk of bias data for the given assessment.
This includes only final reviews.
Expand All @@ -26,7 +26,7 @@ def data(self, assessment_id: int) -> pd.DataFrame:
response_json = self.session.get(url).json()
return pd.DataFrame(response_json)

def full_data(self, assessment_id: int) -> pd.DataFrame:
def full_export(self, assessment_id: int) -> pd.DataFrame:
"""
Retrieves full risk of bias data for the given assessment.
This includes user-level reviews.
Expand Down
141 changes: 79 additions & 62 deletions hawc/apps/animal/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,82 +10,87 @@
from ..assessment.api import (
AssessmentLevelPermissions,
AssessmentViewset,
DoseUnitsViewset,
get_assessment_from_query,
get_assessment_id_param,
)
from ..assessment.models import Assessment
from ..common.api import (
CleanupFieldsBaseViewSet,
LegacyAssessmentAdapterMixin,
user_can_edit_object,
DoseUnitsViewset,
)
from ..assessment.constants import AssessmentViewSetPermissions
from ..common.helper import FlatExport, re_digits
from ..common.renderers import PandasRenderers
from ..common.serializers import HeatmapQuerySerializer, UnusedSerializer
from ..common.views import AssessmentPermissionsMixin, create_object_log
from ..common.views import create_object_log
from . import exports, models, serializers
from .actions.model_metadata import AnimalMetadata
from .actions.term_check import term_check


class AnimalAssessmentViewset(
AssessmentPermissionsMixin, LegacyAssessmentAdapterMixin, viewsets.GenericViewSet
):
parent_model = Assessment
model = models.Endpoint
class AnimalAssessmentViewset(viewsets.GenericViewSet):
model = models.Assessment
queryset = models.Assessment.objects.all()
permission_classes = (AssessmentLevelPermissions,)
action_perms = {}
serializer_class = UnusedSerializer
lookup_value_regex = re_digits

def get_queryset(self):
perms = self.get_obj_perms()
def get_endpoint_queryset(self):
perms = self.assessment.user_permissions(self.request.user)
if not perms["edit"]:
return self.model.objects.published(self.assessment)
return self.model.objects.get_qs(self.assessment)

@action(detail=True, url_path="full-export", renderer_classes=PandasRenderers)
return models.Endpoint.objects.published(self.assessment)
return models.Endpoint.objects.get_qs(self.assessment)

@action(
detail=True,
url_path="full-export",
action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT,
renderer_classes=PandasRenderers,
)
def full_export(self, request, pk):
"""
Retrieve complete animal data
"""
self.set_legacy_attr(pk)
self.permission_check_user_can_view()
self.assessment = self.get_object()
exporter = exports.EndpointGroupFlatComplete(
self.get_queryset(),
self.get_endpoint_queryset(),
filename=f"{self.assessment}-bioassay-complete",
assessment=self.assessment,
)
return Response(exporter.build_export())

@action(detail=True, url_path="endpoint-export", renderer_classes=PandasRenderers)
@action(
detail=True,
url_path="endpoint-export",
action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT,
renderer_classes=PandasRenderers,
)
def endpoint_export(self, request, pk):
"""
Retrieve endpoint animal data
"""
self.set_legacy_attr(pk)
self.permission_check_user_can_view()
self.assessment = self.get_object()
exporter = exports.EndpointSummary(
self.get_queryset(),
self.get_endpoint_queryset(),
filename=f"{self.assessment}-bioassay-summary",
assessment=self.assessment,
)
return Response(exporter.build_export())

@action(detail=True, url_path="study-heatmap", renderer_classes=PandasRenderers)
@action(
detail=True,
url_path="study-heatmap",
action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT,
renderer_classes=PandasRenderers,
)
def study_heatmap(self, request, pk):
"""
Return heatmap data for assessment, at the study-level (one row per study).
By default only shows data from published studies. If the query param `unpublished=true`
is present then results from all studies are shown.
"""
self.set_legacy_attr(pk)
self.permission_check_user_can_view()
self.assessment = self.get_object()
ser = HeatmapQuerySerializer(data=request.query_params)
ser.is_valid(raise_exception=True)
unpublished = ser.data["unpublished"]
if unpublished and not self.assessment.user_is_part_of_team(self.request.user):
if unpublished and not self.assessment.user_is_reviewer_or_higher(self.request.user):
raise PermissionDenied("You must be part of the team to view unpublished data")
key = f"assessment-{self.assessment.id}-bioassay-study-heatmap-pub-{unpublished}"
df = cache.get(key)
Expand All @@ -95,20 +100,24 @@ def study_heatmap(self, request, pk):
export = FlatExport(df=df, filename=f"bio-study-heatmap-{self.assessment.id}")
return Response(export)

@action(detail=True, url_path="endpoint-heatmap", renderer_classes=PandasRenderers)
@action(
detail=True,
url_path="endpoint-heatmap",
action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT,
renderer_classes=PandasRenderers,
)
def endpoint_heatmap(self, request, pk):
"""
Return heatmap data for assessment, at the endpoint level (one row per endpoint).
By default only shows data from published studies. If the query param `unpublished=true`
is present then results from all studies are shown.
"""
self.set_legacy_attr(pk)
self.permission_check_user_can_view()
self.assessment = self.get_object()
ser = HeatmapQuerySerializer(data=request.query_params)
ser.is_valid(raise_exception=True)
unpublished = ser.data["unpublished"]
if unpublished and not self.assessment.user_is_part_of_team(self.request.user):
if unpublished and not self.assessment.user_is_reviewer_or_higher(self.request.user):
raise PermissionDenied("You must be part of the team to view unpublished data")
key = f"assessment-{self.assessment.id}-bioassay-endpoint-heatmap-unpublished-{unpublished}"
df = cache.get(key)
Expand All @@ -118,20 +127,24 @@ def endpoint_heatmap(self, request, pk):
export = FlatExport(df=df, filename=f"bio-endpoint-heatmap-{self.assessment.id}")
return Response(export)

@action(detail=True, url_path="endpoint-doses-heatmap", renderer_classes=PandasRenderers)
@action(
detail=True,
url_path="endpoint-doses-heatmap",
action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT,
renderer_classes=PandasRenderers,
)
def endpoint_doses_heatmap(self, request, pk):
"""
Return heatmap data with doses for assessment, at the {endpoint + dose unit} level.
By default only shows data from published studies. If the query param `unpublished=true`
is present then results from all studies are shown.
"""
self.set_legacy_attr(pk)
self.permission_check_user_can_view()
self.assessment = self.get_object()
ser = HeatmapQuerySerializer(data=request.query_params)
ser.is_valid(raise_exception=True)
unpublished = ser.data["unpublished"]
if unpublished and not self.assessment.user_is_part_of_team(self.request.user):
if unpublished and not self.assessment.user_is_reviewer_or_higher(self.request.user):
raise PermissionDenied("You must be part of the team to view unpublished data")
key = f"assessment-{self.assessment.id}-bioassay-endpoint-doses-heatmap-unpublished-{unpublished}"
df = cache.get(key)
Expand All @@ -141,14 +154,17 @@ def endpoint_doses_heatmap(self, request, pk):
export = FlatExport(df=df, filename=f"bio-endpoint-doses-heatmap-{self.assessment.id}")
return Response(export)

@action(detail=True, renderer_classes=PandasRenderers)
@action(
detail=True,
action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT,
renderer_classes=PandasRenderers,
)
def endpoints(self, request, pk):
self.set_legacy_attr(pk)
self.permission_check_user_can_view()
self.assessment = self.get_object()
ser = HeatmapQuerySerializer(data=request.query_params)
ser.is_valid(raise_exception=True)
unpublished = ser.data["unpublished"]
if unpublished and not self.assessment.user_is_part_of_team(self.request.user):
if unpublished and not self.assessment.user_is_reviewer_or_higher(self.request.user):
raise PermissionDenied("You must be part of the team to view unpublished data")
key = f"assessment-{self.assessment.id}-bioassay-endpoint-list"
df = cache.get(key)
Expand All @@ -160,10 +176,14 @@ def endpoints(self, request, pk):
export = FlatExport(df=df, filename=f"bio-endpoint-list-{self.assessment.id}")
return Response(export)

@action(detail=True, url_path="ehv-check", renderer_classes=PandasRenderers)
@action(
detail=True,
url_path="ehv-check",
action_perms=AssessmentViewSetPermissions.TEAM_MEMBER_OR_HIGHER,
renderer_classes=PandasRenderers,
)
def ehv_check(self, request, pk):
self.set_legacy_attr(pk)
self.permission_check_user_can_edit()
_ = self.get_object()
df = term_check(pk)
export = FlatExport(df, f"term-report-{pk}")
return Response(export)
Expand All @@ -173,6 +193,7 @@ class Experiment(mixins.CreateModelMixin, AssessmentViewset):
assessment_filter_args = "study__assessment"
model = models.Experiment
serializer_class = serializers.ExperimentSerializer
permission_classes = (AssessmentLevelPermissions,)

def get_queryset(self):
return (
Expand All @@ -184,8 +205,6 @@ def get_queryset(self):

@transaction.atomic
def perform_create(self, serializer):
# permissions check
user_can_edit_object(serializer.study, self.request.user, raise_exception=True)
super().perform_create(serializer)
create_object_log(
"Created",
Expand All @@ -199,6 +218,7 @@ class AnimalGroup(mixins.CreateModelMixin, AssessmentViewset):
assessment_filter_args = "experiment__study__assessment"
model = models.AnimalGroup
serializer_class = serializers.AnimalGroupSerializer
permission_classes = (AssessmentLevelPermissions,)

@transaction.atomic
def create(self, request, *args, **kwargs):
Expand Down Expand Up @@ -241,7 +261,8 @@ class Endpoint(mixins.CreateModelMixin, AssessmentViewset):
assessment_filter_args = "assessment"
model = models.Endpoint
serializer_class = serializers.EndpointSerializer
list_actions = ["list", "effects", "rob_filter"]
list_actions = ["list", "effects", "rob_filter", "update_terms"]
permission_classes = (AssessmentLevelPermissions,)

def get_queryset(self):
return self.model.objects.optimized_qs()
Expand All @@ -256,19 +277,17 @@ def perform_create(self, serializer):
self.request.user.id,
)

@action(detail=False)
@action(detail=False, action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT)
def effects(self, request):
assessment_id = get_assessment_id_param(self.request)
effects = models.Endpoint.objects.get_effects(assessment_id)
effects = models.Endpoint.objects.get_effects(self.assessment.id)
return Response(effects)

@action(detail=False)
@action(detail=False, action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT)
def rob_filter(self, request):

params = request.query_params
assessment_id = get_assessment_id_param(request)

query = Q(assessment_id=assessment_id)
query = Q(assessment=self.assessment)

effects = params.get("effect[]")
if effects:
Expand All @@ -286,16 +305,14 @@ def rob_filter(self, request):
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data)

@action(detail=False, methods=("post",))
@action(
detail=False, methods=("post",), action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT
)
def update_terms(self, request):
# check assessment level permissions
assessment = get_assessment_from_query(request)
if not assessment.user_can_edit_object(request.user):
self.permission_denied(request)
# update endpoint terms (all other validation done in manager)
updated_endpoints = self.model.objects.update_terms(request.data, assessment)
updated_endpoints = self.model.objects.update_terms(request.data, self.assessment)
serializer = serializers.EndpointSerializer(updated_endpoints, many=True)
assessment.bust_cache()
self.assessment.bust_cache()
return Response(serializer.data)


Expand Down
4 changes: 3 additions & 1 deletion hawc/apps/animal/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
from django.db import transaction
from rest_framework import serializers

from ..assessment.api import user_can_edit_object
from ..assessment.models import DoseUnits, DSSTox
from ..assessment.serializers import DSSToxSerializer, EffectTagsSerializer
from ..bmd.serializers import ModelSerializer
from ..common.api import DynamicFieldsMixin, user_can_edit_object
from ..common.api import DynamicFieldsMixin
from ..common.helper import SerializerHelper
from ..common.serializers import get_matching_instance, get_matching_instances
from ..study.models import Study
Expand All @@ -30,6 +31,7 @@ def to_representation(self, instance):
def validate(self, data):
# Validate parent object
self.study = get_matching_instance(Study, self.initial_data, "study_id")
user_can_edit_object(self.study, self.context["request"].user, raise_exception=True)

# add additional checks from forms.ExperimentForm
form = forms.ExperimentForm(data=data, parent=self.study)
Expand Down
4 changes: 2 additions & 2 deletions hawc/apps/animal/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,8 @@ def get_app_config(self, context) -> WebappConfig:
class EndpointTags(EndpointFilterList):
# List of Endpoints associated with an assessment and tag

def get_base_queryset(self):
return self.model.objects.tag_qs(self.assessment.pk, self.kwargs["tag_slug"])
def get_queryset(self):
return super().get_queryset().tag_qs(self.assessment.pk, self.kwargs["tag_slug"])


class EndpointRead(BaseDetail):
Expand Down
4 changes: 4 additions & 0 deletions hawc/apps/assessment/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .filters import * # noqa: F401,F403
from .helper import * # noqa: F401,F403
from .permissions import * # noqa: F401,F403
from .viewsets import * # noqa: F401,F403
25 changes: 25 additions & 0 deletions hawc/apps/assessment/api/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from rest_framework import filters

from .helper import get_assessment_from_query


class InAssessmentFilter(filters.BaseFilterBackend):
"""
Filter objects which are in a particular assessment.
"""

default_list_actions = ["list"]

def filter_queryset(self, request, queryset, view):
list_actions = getattr(view, "list_actions", self.default_list_actions)
if view.action not in list_actions:
return queryset

if not hasattr(view, "assessment"):
view.assessment = get_assessment_from_query(request)

if not view.assessment_filter_args:
raise ValueError("Viewset requires the `assessment_filter_args` argument")

filters = {view.assessment_filter_args: view.assessment.id}
return queryset.filter(**filters)
Loading

0 comments on commit efaa660

Please sign in to comment.