diff --git a/client/hawc_client/riskofbias.py b/client/hawc_client/riskofbias.py index b7a711461d..0676242f21 100644 --- a/client/hawc_client/riskofbias.py +++ b/client/hawc_client/riskofbias.py @@ -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. @@ -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. diff --git a/hawc/apps/animal/api.py b/hawc/apps/animal/api.py index c123e7c2ba..8ff297fc3e 100644 --- a/hawc/apps/animal/api.py +++ b/hawc/apps/animal/api.py @@ -10,69 +10,75 @@ 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). @@ -80,12 +86,11 @@ def study_heatmap(self, request, pk): 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) @@ -95,7 +100,12 @@ 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). @@ -103,12 +113,11 @@ def endpoint_heatmap(self, request, pk): 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) @@ -118,7 +127,12 @@ 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. @@ -126,12 +140,11 @@ def endpoint_doses_heatmap(self, request, pk): 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) @@ -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) @@ -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) @@ -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 ( @@ -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", @@ -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): @@ -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() @@ -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: @@ -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) diff --git a/hawc/apps/animal/serializers.py b/hawc/apps/animal/serializers.py index e5f62e7434..537cbe70b6 100644 --- a/hawc/apps/animal/serializers.py +++ b/hawc/apps/animal/serializers.py @@ -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 @@ -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) diff --git a/hawc/apps/animal/views.py b/hawc/apps/animal/views.py index bb4fb8e293..043a5b3f90 100644 --- a/hawc/apps/animal/views.py +++ b/hawc/apps/animal/views.py @@ -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): diff --git a/hawc/apps/assessment/api/__init__.py b/hawc/apps/assessment/api/__init__.py new file mode 100644 index 0000000000..1c9a3f032f --- /dev/null +++ b/hawc/apps/assessment/api/__init__.py @@ -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 diff --git a/hawc/apps/assessment/api/filters.py b/hawc/apps/assessment/api/filters.py new file mode 100644 index 0000000000..482b4210c5 --- /dev/null +++ b/hawc/apps/assessment/api/filters.py @@ -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) diff --git a/hawc/apps/assessment/api/helper.py b/hawc/apps/assessment/api/helper.py new file mode 100644 index 0000000000..254109428e --- /dev/null +++ b/hawc/apps/assessment/api/helper.py @@ -0,0 +1,36 @@ +from typing import Optional + +from rest_framework import status +from rest_framework.exceptions import APIException + +from ...common.helper import tryParseInt +from .. import models + + +class RequiresAssessmentID(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Please provide an `assessment_id` argument to your GET request." + + +class InvalidAssessmentID(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Assessment does not exist for given `assessment_id`." + + +def get_assessment_id_param(request) -> int: + """ + If request doesn't contain an integer-based `assessment_id`, an exception is raised. + """ + assessment_id = tryParseInt(request.GET.get("assessment_id")) + if assessment_id is None: + raise RequiresAssessmentID() + return assessment_id + + +def get_assessment_from_query(request) -> Optional[models.Assessment]: + """Returns assessment or raises exception if does not exist.""" + assessment_id = get_assessment_id_param(request) + try: + return models.Assessment.objects.get(pk=assessment_id) + except models.Assessment.DoesNotExist: + raise InvalidAssessmentID() diff --git a/hawc/apps/assessment/api/permissions.py b/hawc/apps/assessment/api/permissions.py new file mode 100644 index 0000000000..0d80e145b0 --- /dev/null +++ b/hawc/apps/assessment/api/permissions.py @@ -0,0 +1,150 @@ +import logging +from collections import ChainMap + +from django.db import models +from rest_framework import exceptions, permissions + +from ...assessment.constants import AssessmentViewSetPermissions +from .helper import get_assessment_from_query + +logger = logging.getLogger(__name__) + + +def user_can_edit_object( + instance: models.Model, user: models.Model, raise_exception: bool = False +) -> bool: + """Permissions check to ensure that user can edit assessment objects + + Args: + instance (models.Model): The instance to check + user (models.Model): The user instance + raise_exception (bool, optional): Throw an Exception; defaults to False. + + Raises: + exceptions.PermissionDenied: If raise_exc is True and user doesn't have permission + + """ + can_edit = instance.get_assessment().user_can_edit_object(user) + if raise_exception and not can_edit: + raise exceptions.PermissionDenied("Invalid permission to edit assessment.") + return can_edit + + +class CleanupFieldsPermissions(permissions.BasePermission): + """ + Custom permissions for bulk-cleanup view. No object-level permissions. Here we check that + the user has permission to edit content for this assessment, but not necessarily that they + can edit the specific ids selected. + """ + + def has_object_permission(self, request, view, obj): + # no object-specific permissions + return False + + def has_permission(self, request, view): + # must be team-member or higher to bulk-edit + view.assessment = get_assessment_from_query(request) + return view.assessment.user_can_edit_object(request.user) + + +class AssessmentLevelPermissions(permissions.BasePermission): + """ + Permission class that handles assessment level permissions. + + Action permissions can be set on a viewset using the class property action_perms + or passed directly into an action decorator with the action_perms kwarg. + action_perms can be a dict mapping action name to the coinciding AssessmentViewsetPermission, + or it can just be an AssessmentViewsetPermission if no mapping is necessary. + + Note: This permission class does NOT handle the create action, since there is no way to tell + if the object being created has assessment permissions at this level. Permissions for this + action should instead be checked in the corresponding serializer or create method. + """ + + default_list_actions = ["list"] + default_action_perms = { + "retrieve": AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + "list": AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + "update": AssessmentViewSetPermissions.CAN_EDIT_OBJECT, + "partial_update": AssessmentViewSetPermissions.CAN_EDIT_OBJECT, + "destroy": AssessmentViewSetPermissions.CAN_EDIT_OBJECT, + "metadata": AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + } + + def fix_view_action(self, request, view): + """ + BrowsableAPIRenderer (ie the renderer used when DEBUG=TRUE) interacts directly with the + view when building its forms, and in the process overrides view.action with None on + its OPTIONS requests. + + BrowsableAPIRenderer calls override_method: + https://github.com/encode/django-rest-framework/blob/bfce663a604bf9d2c891ab2414fee0e59cabeb46/rest_framework/renderers.py#L474 + which uses view.action_map to set view.action: + https://github.com/encode/django-rest-framework/blob/bfce663a604bf9d2c891ab2414fee0e59cabeb46/rest_framework/request.py#L55 + but OPTIONS isn't included in the mapping on default routes (ie view.action_map): + https://github.com/encode/django-rest-framework/blob/bfce663a604bf9d2c891ab2414fee0e59cabeb46/rest_framework/routers.py#L95-L136 + + The action for OPTIONS requests is instead added explicitly and by default when the request is initialized: + https://github.com/encode/django-rest-framework/blob/bfce663a604bf9d2c891ab2414fee0e59cabeb46/rest_framework/viewsets.py#L148-L152 + + The fix is to explicitly set the view.action once again if it is None and the method is OPTIONS. + """ + if view.action is None and request.method == "OPTIONS": + view.action = "metadata" + + def assessment_permission(self, view): + action_perms = getattr(view, "action_perms", {}) + if isinstance(action_perms, dict): # viewset + return ChainMap(action_perms, self.default_action_perms)[view.action] + return action_perms # custom action + + def has_object_permission(self, request, view, obj): + if not hasattr(view, "assessment"): + view.assessment = obj.get_assessment() + return self.assessment_permission(view).has_permission(view.assessment, request.user) + + def has_permission(self, request, view): + self.fix_view_action(request, view) + + list_actions = getattr(view, "list_actions", self.default_list_actions) + if view.action in list_actions: + logger.debug("Permission checked") + + if not hasattr(view, "assessment"): + view.assessment = get_assessment_from_query(request) + + return self.assessment_permission(view).has_permission(view.assessment, request.user) + + return True + + +class JobPermissions(permissions.BasePermission): + """ + Requires admin permissions where jobs have no associated assessment + or when part of a list, and assessment level permissions when jobs + have an associated assessment. + """ + + def has_object_permission(self, request, view, obj): + if obj.assessment is None: + return bool(request.user and request.user.is_staff) + elif request.method in permissions.SAFE_METHODS: + return obj.assessment.user_can_view_object(request.user) + else: + return obj.assessment.user_can_edit_object(request.user) + + def has_permission(self, request, view): + if view.action == "list": + return bool(request.user and request.user.is_staff) + elif view.action == "create": + serializer = view.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + assessment = serializer.validated_data.get("assessment") + if assessment is None: + return bool(request.user and request.user.is_staff) + else: + return assessment.user_can_edit_object(request.user) + else: + # other actions are object specific, + # and will be caught by object permissions + return True diff --git a/hawc/apps/assessment/api.py b/hawc/apps/assessment/api/viewsets.py similarity index 65% rename from hawc/apps/assessment/api.py rename to hawc/apps/assessment/api/viewsets.py index 523fef8760..528bff4dd8 100644 --- a/hawc/apps/assessment/api.py +++ b/hawc/apps/assessment/api/viewsets.py @@ -1,154 +1,136 @@ -import logging from pathlib import Path -from typing import Optional from django.apps import apps -from django.core import exceptions from django.db import transaction -from django.db.models import Count +from django.db.models import Count, Model from django.http import Http404 from django.urls import reverse from django_filters.rest_framework.backends import DjangoFilterBackend -from rest_framework import filters, mixins, permissions, status, viewsets +from rest_framework import mixins, permissions, viewsets from rest_framework.decorators import action -from rest_framework.exceptions import APIException, PermissionDenied -from rest_framework.pagination import PageNumberPagination +from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response -from hawc.services.epa import dsstox +from ....services.epa.dsstox import RE_DTXSID +from ...common.api import CleanupBulkIdFilter, DisabledPagination, ListUpdateModelMixin +from ...common.exceptions import ClassConfigurationException +from ...common.helper import FlatExport, re_digits +from ...common.renderers import PandasRenderers +from ...common.views import bulk_create_object_log, create_object_log +from .. import models, serializers +from ..actions.audit import AssessmentAuditSerializer +from ..constants import AssessmentViewSetPermissions +from .filters import InAssessmentFilter +from .permissions import AssessmentLevelPermissions, CleanupFieldsPermissions, user_can_edit_object + +# all http methods except PUT +METHODS_NO_PUT = ["get", "post", "patch", "delete", "head", "options", "trace"] -from ..common.helper import FlatExport, re_digits, tryParseInt -from ..common.renderers import PandasRenderers -from ..common.views import create_object_log -from . import models, serializers -from .actions.audit import AssessmentAuditSerializer +class CleanupFieldsBaseViewSet( + ListUpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + """ + Base Viewset for bulk updating text fields. -class DisabledPagination(PageNumberPagination): - page_size = None + Implements three routes: + - GET /?assessment_id=1: list data available for cleanup + - PATCH /?assessment_id=1&ids=1,2,3: modify selected data + - GET /fields/: list fields available for cleanup -logger = logging.getLogger(__name__) + Model should have a TEXT_CLEANUP_FIELDS class attribute which is list of fields. + For bulk update, 'X-CUSTOM-BULK-OPERATION' header must be provided. + Serializer should implement DynamicFieldsMixin. + """ + model: Model = None # must be added + assessment_filter_args: str = "" # must be added + filter_backends = (CleanupBulkIdFilter,) + pagination_class = DisabledPagination + permission_classes = (CleanupFieldsPermissions,) + template_name = "assessment/endpointcleanup_list.html" -class RequiresAssessmentID(APIException): - status_code = status.HTTP_400_BAD_REQUEST - default_detail = "Please provide an `assessment_id` argument to your GET request." + def get_queryset(self): + return self.model.objects.all() + + @action(detail=False, methods=["get"]) + def fields(self, request, format=None): + """ + Return field names available for cleanup. + """ + cleanup_fields = self.model.TEXT_CLEANUP_FIELDS + TERM_FIELD_MAPPING = getattr(self.model, "TERM_FIELD_MAPPING", {}) + return Response( + {"text_cleanup_fields": cleanup_fields, "term_field_mapping": TERM_FIELD_MAPPING} + ) + def partial_update_bulk(self, request, *args, **kwargs): + return super().partial_update_bulk(request, *args, **kwargs) -class InvalidAssessmentID(APIException): - status_code = status.HTTP_400_BAD_REQUEST - default_detail = "Assessment does not exist for given `assessment_id`." + def post_save_bulk(self, queryset, update_bulk_dict): + ids = list(queryset.values_list("id", flat=True)) + bulk_create_object_log("Updated", queryset, self.request.user.id) + queryset.model.delete_caches(ids) -def get_assessment_id_param(request) -> int: +class EditPermissionsCheckMixin: """ - If request doesn't contain an integer-based `assessment_id`, an exception is raised. + API Viewset mixin which provides permission checking during create/update/destroy operations. + + Fires "user_can_edit_object" checks during requests to create/update/destroy. Viewsets mixing + this in can define a variable "edit_check_keys", which is a list of serializer attribute + keys that should be used as the source for the checks. """ - assessment_id = tryParseInt(request.GET.get("assessment_id")) - if assessment_id is None: - raise RequiresAssessmentID() - return assessment_id + def get_object_checks(self, serializer): + """ + Generates a list of model objects to check permissions against. Each object returned + can then be checked using user_can_edit_object, throwing an exception if necessary. -def get_assessment_from_query(request) -> Optional[models.Assessment]: - """Returns assessment or raises exception if does not exist.""" - assessment_id = get_assessment_id_param(request) - try: - return models.Assessment.objects.get(pk=assessment_id) - except models.Assessment.DoesNotExist: - raise InvalidAssessmentID() + Args: + serializer: the serializer of the associated viewset + Returns: + List: A list of django model instances + """ + objects = [] -class JobPermissions(permissions.BasePermission): - """ - Requires admin permissions where jobs have no associated assessment - or when part of a list, and assessment level permissions when jobs - have an associated assessment. - """ + # if thing already is created, check that we can edit it + if serializer.instance and serializer.instance.pk: + objects.append(serializer.instance) - def has_object_permission(self, request, view, obj): - if obj.assessment is None: - return bool(request.user and request.user.is_staff) - elif request.method in permissions.SAFE_METHODS: - return obj.assessment.user_can_view_object(request.user) - else: - return obj.assessment.user_can_edit_object(request.user) - - def has_permission(self, request, view): - if view.action == "list": - return bool(request.user and request.user.is_staff) - elif view.action == "create": - serializer = view.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) - assessment = serializer.validated_data.get("assessment") - if assessment is None: - return bool(request.user and request.user.is_staff) - else: - return assessment.user_can_edit_object(request.user) - else: - # other actions are object specific, - # and will be caught by object permissions - return True - - -class AssessmentLevelPermissions(permissions.BasePermission): - default_list_actions = ["list"] - - def has_object_permission(self, request, view, obj): - if not hasattr(view, "assessment"): - view.assessment = obj.get_assessment() - if request.method in permissions.SAFE_METHODS: - return view.assessment.user_can_view_object(request.user) - elif obj == view.assessment: - return view.assessment.user_can_edit_assessment(request.user) - else: - return view.assessment.user_can_edit_object(request.user) - - def has_permission(self, request, view): - list_actions = getattr(view, "list_actions", self.default_list_actions) - if view.action in list_actions: - logger.debug("Permission checked") - - if not hasattr(view, "assessment"): - view.assessment = get_assessment_from_query(request) - - return view.assessment.user_can_view_object(request.user) - - return True - - -class AssessmentReadPermissions(AssessmentLevelPermissions): - def has_object_permission(self, request, view, obj): - if not hasattr(view, "assessment"): - view.assessment = obj.get_assessment() - return view.assessment.user_can_view_object(request.user) - - -class InAssessmentFilter(filters.BaseFilterBackend): - """ - Filter objects which are in a particular assessment. - """ + # additional checks on other attributes + for checker_key in getattr(self, "edit_check_keys", []): + if checker_key in serializer.validated_data: + objects.append(serializer.validated_data.get(checker_key)) - default_list_actions = ["list"] + # ensure we have at least one object to check + if len(objects) == 0: + raise ClassConfigurationException("Permission check required; nothing to check") - 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 + return objects - if not hasattr(view, "assessment"): - view.assessment = get_assessment_from_query(request) + def perform_create(self, serializer): + for object_ in self.get_object_checks(serializer): + user_can_edit_object(object_, self.request.user, raise_exception=True) + super().perform_create(serializer) - if not view.assessment_filter_args: - raise ValueError("Viewset requires the `assessment_filter_args` argument") + def perform_update(self, serializer): + for object_ in self.get_object_checks(serializer): + user_can_edit_object(object_, self.request.user, raise_exception=True) + super().perform_update(serializer) - filters = {view.assessment_filter_args: view.assessment.id} - return queryset.filter(**filters) + def perform_destroy(self, instance): + user_can_edit_object(instance, self.request.user, raise_exception=True) + super().perform_destroy(instance) class BaseAssessmentViewset(viewsets.GenericViewSet): + action_perms = {} assessment_filter_args = "" permission_classes = (AssessmentLevelPermissions,) filter_backends = (InAssessmentFilter,) @@ -158,18 +140,11 @@ def get_queryset(self): return self.model.objects.all() -class AssessmentViewset(mixins.RetrieveModelMixin, mixins.ListModelMixin, BaseAssessmentViewset): - pass - - -# all http methods except PUT -METHODS_NO_PUT = ["get", "post", "patch", "delete", "head", "options", "trace"] - - class AssessmentEditViewset(viewsets.ModelViewSet): http_method_names = METHODS_NO_PUT assessment_filter_args = "" permission_classes = (AssessmentLevelPermissions,) + action_perms = {} parent_model = models.Assessment filter_backends = (InAssessmentFilter,) lookup_value_regex = re_digits @@ -203,6 +178,10 @@ def perform_destroy(self, instance): super().perform_destroy(instance) +class AssessmentViewset(mixins.RetrieveModelMixin, mixins.ListModelMixin, BaseAssessmentViewset): + pass + + class AssessmentRootedTagTreeViewset(viewsets.ModelViewSet): """ Base viewset used with utils/models/AssessmentRootedTagTree subclasses @@ -212,10 +191,7 @@ class AssessmentRootedTagTreeViewset(viewsets.ModelViewSet): lookup_value_regex = re_digits permission_classes = (AssessmentLevelPermissions,) - - PROJECT_MANAGER = "PROJECT_MANAGER" - TEAM_MEMBER = "TEAM_MEMBER" - create_requires = TEAM_MEMBER + action_perms = {} def get_queryset(self): return self.model.objects.all() @@ -225,13 +201,6 @@ def list(self, request): data = self.model.get_all_tags(self.assessment.id) return Response(data) - def create(self, request, *args, **kwargs): - # get an assessment - assessment_id = get_assessment_id_param(self.request) - self.assessment = models.Assessment.objects.filter(id=assessment_id).first() - self.check_editing_permission(request) - return super().create(request, *args, **kwargs) - @transaction.atomic def perform_create(self, serializer): super().perform_create(serializer) @@ -258,28 +227,17 @@ def perform_destroy(self, instance): super().perform_destroy(instance) @transaction.atomic - @action(detail=True, methods=("patch",)) + @action( + detail=True, methods=("patch",), action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT + ) def move(self, request, *args, **kwargs): instance = self.get_object() - self.assessment = instance.get_assessment() - self.check_editing_permission(request) instance.moveWithinSiblingsToIndex(request.data["newIndex"]) create_object_log( "Updated (moved)", instance, instance.get_assessment().id, self.request.user.id ) return Response({"status": True}) - def check_editing_permission(self, request): - if self.create_requires == self.PROJECT_MANAGER: - permissions_check = self.assessment.user_can_edit_assessment - elif self.create_requires == self.TEAM_MEMBER: - permissions_check = self.assessment.user_can_edit_object - else: - raise ValueError("invalid configuration of `create_requires`") - - if not permissions_check(request.user): - raise exceptions.PermissionDenied() - class DoseUnitsViewset(mixins.ListModelMixin, viewsets.GenericViewSet): model = models.DoseUnits @@ -302,7 +260,7 @@ def public(self, request): serializer = serializers.AssessmentSerializer(queryset, many=True) return Response(serializer.data) - @action(detail=True) + @action(detail=True, action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT) def endpoints(self, request, pk: int): """ Optimized for queryset speed; some counts in get_queryset @@ -460,11 +418,14 @@ def endpoints(self, request, pk: int): return Response({"name": instance.name, "id": instance.id, "items": items}) - @action(detail=True, url_path=r"logs/(?P[\w]+)", renderer_classes=PandasRenderers) + @action( + detail=True, + url_path=r"logs/(?P[\w]+)", + action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT, + renderer_classes=PandasRenderers, + ) def logs(self, request: Request, pk: int, type: str): instance = self.get_object() - if not instance.user_is_team_member_or_higher(self.request.user): - raise PermissionDenied() serializer = AssessmentAuditSerializer.from_drf(data=dict(assessment=instance, type=type)) export = serializer.export() return Response(export) @@ -487,7 +448,11 @@ def get_queryset(self): return self.model.objects.get_qs(self.assessment) return self.model.objects.all() - @action(detail=True, renderer_classes=PandasRenderers) + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def data(self, request, pk: int = None): instance = self.get_object() revision = instance.get_latest_revision() @@ -496,11 +461,14 @@ def data(self, request, pk: int = None): export = FlatExport(df=revision.get_df(), filename=Path(revision.metadata["filename"]).stem) return Response(export) - @action(detail=True, renderer_classes=PandasRenderers, url_path=r"version/(?P\d+)") + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.TEAM_MEMBER_OR_HIGHER, + renderer_classes=PandasRenderers, + url_path=r"version/(?P\d+)", + ) def version(self, request, pk: int, version: int): instance = self.get_object() - if not self.assessment.user_is_team_member_or_higher(request.user): - raise PermissionDenied() revision = instance.revisions.filter(version=version).first() if revision is None or not revision.data_exists(): raise Http404() @@ -510,7 +478,7 @@ def version(self, request, pk: int, version: int): class DssToxViewset(viewsets.GenericViewSet, mixins.RetrieveModelMixin): permission_classes = (permissions.AllowAny,) - lookup_value_regex = dsstox.RE_DTXSID + lookup_value_regex = RE_DTXSID model = models.DSSTox serializer_class = serializers.DSSToxSerializer diff --git a/hawc/apps/assessment/constants.py b/hawc/apps/assessment/constants.py index 3e7e1dafe0..b55d12ad0a 100644 --- a/hawc/apps/assessment/constants.py +++ b/hawc/apps/assessment/constants.py @@ -1,5 +1,25 @@ from django.db import models +from .permissions import AssessmentPermissions + + +class AssessmentViewPermissions(models.IntegerChoices): + PROJECT_MANAGER = 1 + TEAM_MEMBER = 2 + VIEWER = 3 + + +class AssessmentViewSetPermissions(models.IntegerChoices): + CAN_VIEW_OBJECT = 1 + CAN_EDIT_OBJECT = 2 + CAN_EDIT_ASSESSMENT = 3 + TEAM_MEMBER_OR_HIGHER = 4 + PROJECT_MANAGER_OR_HIGHER = 5 + + def has_permission(self, assessment, user, **kwargs): + perms = AssessmentPermissions.get(assessment) + return getattr(perms, self.name.lower())(user, **kwargs) + class NoelName(models.IntegerChoices): NEL = 2, "NEL/LEL" diff --git a/hawc/apps/assessment/models.py b/hawc/apps/assessment/models.py index b9eb215c43..a07b23ffaf 100644 --- a/hawc/apps/assessment/models.py +++ b/hawc/apps/assessment/models.py @@ -327,16 +327,21 @@ def user_can_edit_object(self, user, perms: AssessmentPermissions = None) -> boo def user_can_edit_assessment(self, user, perms: AssessmentPermissions = None) -> bool: if perms is None: perms = self.get_permissions() - return perms.can_edit_assessment(user) + return perms.project_manager_or_higher(user) - def user_is_part_of_team(self, user) -> bool: + def user_is_reviewer_or_higher(self, user) -> bool: + """Reviewer or higher""" perms = self.get_permissions() - return perms.part_of_team(user) + return perms.reviewer_or_higher(user) def user_is_team_member_or_higher(self, user) -> bool: perms = self.get_permissions() return perms.team_member_or_higher(user) + def user_is_project_manager_or_higher(self, user) -> bool: + perms = self.get_permissions() + return perms.project_manager_or_higher(user) + def get_vocabulary_display(self) -> str: # override default method if self.vocabulary: diff --git a/hawc/apps/assessment/permissions.py b/hawc/apps/assessment/permissions.py index 7bbc8856f8..c9915324d5 100644 --- a/hawc/apps/assessment/permissions.py +++ b/hawc/apps/assessment/permissions.py @@ -34,47 +34,31 @@ def get(cls, assessment) -> "AssessmentPermissions": cache.set(key, perms, settings.CACHE_1_HR) return perms - def can_view_object(self, user) -> bool: - """ - Superusers can view all, noneditible reviews can be viewed, team - members or project managers can view. - Anonymous users on noneditable projects cannot view, nor can those who - are non members of a project. - """ - if self.public: - return True - return self.part_of_team(user) - - def can_edit_object(self, user) -> bool: + def project_manager_or_higher(self, user) -> bool: """ - If person has enhanced permissions beyond the general public, which may - be used to view attachments associated with a study. + Check if user is superuser or project-manager """ if user.is_superuser: return True elif user.is_anonymous: return False else: - return self.editable and ( - user.id in self.project_manager or user.id in self.team_members - ) + return user.id in self.project_manager - def can_edit_assessment(self, user) -> bool: + def team_member_or_higher(self, user) -> bool: """ - If person is superuser or assessment is editible and user is a project - manager or team member. + Check if person is superuser, project manager, or team member """ if user.is_superuser: return True elif user.is_anonymous: return False else: - return user.id in self.project_manager + return user.id in self.project_manager or user.id in self.team_members - def part_of_team(self, user) -> bool: + def reviewer_or_higher(self, user) -> bool: """ - Used for permissions-checking if attachments for a study can be - viewed. Checks to ensure user is part of the team. + If person is superuser, project manager, team member, or reviewer """ if user.is_superuser: return True @@ -87,22 +71,33 @@ def part_of_team(self, user) -> bool: or user.id in self.reviewers ) - def team_member_or_higher(self, user) -> bool: + def can_edit_object(self, user) -> bool: """ - Check if user is superuser, project-manager, or team-member, otherwise False. + If person has enhanced permissions beyond the general public, which may + be used to view attachments associated with a study. """ if user.is_superuser: return True - elif user.is_anonymous: - return False - else: - return user.id in self.project_manager or user.id in self.team_members + return self.editable and self.team_member_or_higher(user) + + def can_view_object(self, user) -> bool: + """ + Superusers can view all, noneditible reviews can be viewed, team + members or project managers can view. + Anonymous users on noneditable projects cannot view, nor can those who + are non members of a project. + """ + if self.public: + return True + return self.reviewer_or_higher(user) def can_edit_study(self, study, user) -> bool: """ Check if user can edit a study; dependent on if study is editable """ - return self.can_edit_assessment(user) or (study.editable and self.can_edit_object(user)) + return self.project_manager_or_higher(user) or ( + study.editable and self.can_edit_object(user) + ) def can_view_study(self, study, user) -> bool: """ @@ -114,5 +109,5 @@ def to_dict(self, user, study=None): return { "view": self.can_view_study(study, user) if study else self.can_view_object(user), "edit": self.can_edit_study(study, user) if study else self.can_edit_object(user), - "edit_assessment": self.can_edit_assessment(user), + "edit_assessment": self.project_manager_or_higher(user), } diff --git a/hawc/apps/assessment/views.py b/hawc/apps/assessment/views.py index 2e147cd9d5..e3dcef67b0 100644 --- a/hawc/apps/assessment/views.py +++ b/hawc/apps/assessment/views.py @@ -41,8 +41,6 @@ CloseIfSuccessMixin, LoginRequiredMixin, MessageMixin, - ProjectManagerOrHigherMixin, - TeamMemberOrHigherMixin, TimeSpentOnPageMixin, beta_tester_required, create_object_log, @@ -392,11 +390,11 @@ class AssessmentRead(BaseDetail): model = models.Assessment def get_queryset(self): - qs = super().get_queryset() - qs = qs.prefetch_related( - "project_manager", "team_members", "reviewers", "datasets", "dtxsids" + return ( + super() + .get_queryset() + .prefetch_related("project_manager", "team_members", "reviewers", "datasets", "dtxsids") ) - return qs def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -419,18 +417,21 @@ class AssessmentUpdate(BaseUpdate): success_message = "Assessment updated." model = models.Assessment form_class = forms.AssessmentForm + assessment_permission = constants.AssessmentViewPermissions.PROJECT_MANAGER class AssessmentModulesUpdate(AssessmentUpdate): success_message = "Assessment modules updated." form_class = forms.AssessmentModulesForm template_name = "assessment/assessment_module_form.html" + assessment_permission = constants.AssessmentViewPermissions.PROJECT_MANAGER class AssessmentDelete(BaseDelete): model = models.Assessment success_url = reverse_lazy("portal") success_message = "Assessment deleted." + assessment_permission = constants.AssessmentViewPermissions.PROJECT_MANAGER class AssessmentClearCache(MessageMixin, View): @@ -600,19 +601,13 @@ class BaseEndpointList(BaseList): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - eps = self.model.endpoint.related.related_model.objects.get_qs(self.assessment.id).count() - os = self.model.outcome.related.related_model.objects.get_qs(self.assessment.id).count() - mrs = apps.get_model("epimeta", "metaresult").objects.get_qs(self.assessment.id).count() - iveps = self.model.ivendpoint.related.related_model.objects.get_qs( self.assessment.id ).count() - alleps = eps + os + mrs + iveps - context.update( { "ivendpoints": iveps, @@ -622,11 +617,10 @@ def get_context_data(self, **kwargs): "total_endpoints": alleps, } ) - return context -class CleanExtractedData(TeamMemberOrHigherMixin, BaseEndpointList): +class CleanExtractedData(BaseEndpointList): """ To add a model to clean, - add TEXT_CLEANUP_FIELDS = {...fields} to the model @@ -640,9 +634,7 @@ class CleanExtractedData(TeamMemberOrHigherMixin, BaseEndpointList): breadcrumb_active_name = "Clean extracted data" template_name = "assessment/clean_extracted_data.html" - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.parent_model, pk=kwargs["pk"]) + assessment_permission = constants.AssessmentViewPermissions.TEAM_MEMBER def get_app_config(self, context) -> WebappConfig: return WebappConfig( @@ -696,13 +688,11 @@ def get(self, request, *args, **kwargs): return JsonResponse({"template": get_styles_svg_definition()}) -class CleanStudyRoB(ProjectManagerOrHigherMixin, BaseDetail): +class CleanStudyRoB(BaseDetail): template_name = "assessment/clean_study_rob_scores.html" model = models.Assessment breadcrumb_active_name = "Clean reviews" - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.model, pk=kwargs["pk"]) + assessment_permission = constants.AssessmentViewPermissions.PROJECT_MANAGER def get_app_config(self, context) -> WebappConfig: return WebappConfig( @@ -811,20 +801,20 @@ def get_context_data(self, **kwargs): return context -class AssessmentLogList(TeamMemberOrHigherMixin, BaseList): +class AssessmentLogList(BaseList): parent_model = models.Assessment model = models.Log breadcrumb_active_name = "Logs" template_name = "assessment/assessment_log_list.html" paginate_by = 25 - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(models.Assessment, pk=kwargs["pk"]) + assessment_permission = constants.AssessmentViewPermissions.TEAM_MEMBER def get_queryset(self): - qs = super().get_queryset() - qs = qs.filter(assessment=self.assessment).select_related( - "assessment", "content_type", "user" + qs = ( + super() + .get_queryset() + .filter(assessment=self.assessment) + .select_related("assessment", "content_type", "user") ) self.form = forms.LogFilterForm(self.request.GET, assessment=self.assessment) if self.form.is_valid(): diff --git a/hawc/apps/bmd/api.py b/hawc/apps/bmd/api.py index 0805c3d2f6..8acbb4b0fb 100644 --- a/hawc/apps/bmd/api.py +++ b/hawc/apps/bmd/api.py @@ -2,6 +2,7 @@ from rest_framework.response import Response from ..assessment.api import AssessmentViewset +from ..assessment.constants import AssessmentViewSetPermissions from . import models, serializers, tasks @@ -18,7 +19,9 @@ def get_serializer_class(self): else: return serializers.SessionSerializer - @action(detail=True, methods=["post"]) + @action( + detail=True, methods=["post"], action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT + ) def execute(self, request, pk=None): instance = self.get_object() serializer = self.get_serializer(instance, data=request.data) @@ -27,13 +30,15 @@ def execute(self, request, pk=None): tasks.execute.delay(instance.id) return Response({"started": True}) - @action(detail=True, methods=["get"]) + @action(detail=True, methods=["get"], action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT) def execute_status(self, request, pk=None): # ping until execution is complete session = self.get_object() return Response({"finished": session.is_finished}) - @action(detail=True, methods=("post",)) + @action( + detail=True, methods=("post",), action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT + ) def selected_model(self, request, pk=None): session = self.get_object() serializer = self.get_serializer(data=request.data, context={"session": session}) diff --git a/hawc/apps/bmd/models.py b/hawc/apps/bmd/models.py index 1f4a367eeb..7452a9e8e8 100644 --- a/hawc/apps/bmd/models.py +++ b/hawc/apps/bmd/models.py @@ -9,6 +9,7 @@ from django.utils.timezone import now from ..animal.constants import DataType +from ..animal.models import Endpoint from . import constants, managers @@ -158,7 +159,7 @@ def get_selected_model_url(self): return reverse("bmd:api:session-selected-model", args=[self.id]) @classmethod - def create_new(cls, endpoint): + def create_new(cls, endpoint: Endpoint): dose_units = endpoint.get_doses_json(json_encode=False)[0]["id"] version = endpoint.assessment.bmd_settings.version return cls.objects.create( diff --git a/hawc/apps/bmd/views.py b/hawc/apps/bmd/views.py index 11eb4d578f..8b4a9e6f2a 100644 --- a/hawc/apps/bmd/views.py +++ b/hawc/apps/bmd/views.py @@ -1,19 +1,12 @@ -from django.core.exceptions import BadRequest +from django.core.exceptions import BadRequest, PermissionDenied from django.middleware.csrf import get_token from django.shortcuts import get_object_or_404 from django.views.generic import RedirectView from ..animal.models import Endpoint -from ..assessment.models import Assessment +from ..assessment.constants import AssessmentViewPermissions from ..common.helper import WebappConfig -from ..common.views import ( - BaseDelete, - BaseDetail, - BaseList, - BaseUpdate, - ProjectManagerOrHigherMixin, - TeamMemberOrHigherMixin, -) +from ..common.views import BaseDelete, BaseDetail, BaseList, BaseUpdate from . import forms, models @@ -27,31 +20,26 @@ def get_object(self, **kwargs): return super(AssessSettingsRead, self).get_object(object=obj, **kwargs) -class AssessSettingsUpdate(ProjectManagerOrHigherMixin, BaseUpdate): +class AssessSettingsUpdate(BaseUpdate): success_message = "BMD Settings updated." model = models.AssessmentSettings form_class = forms.AssessmentSettingsForm + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(Assessment, pk=kwargs["pk"]) - -class AssessLogicUpdate(ProjectManagerOrHigherMixin, BaseUpdate): +class AssessLogicUpdate(BaseUpdate): success_message = "BMD logic settings updated." model = models.LogicField form_class = forms.LogicFieldForm - - def get_assessment(self, request, *args, **kwargs): - return self.get_object().get_assessment() + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER # BMD sessions -class SessionCreate(TeamMemberOrHigherMixin, RedirectView): - def get_assessment(self, request, *args, **kwargs): - self.object = get_object_or_404(Endpoint, pk=kwargs["pk"]) - return self.object.assessment - +class SessionCreate(RedirectView): def get_redirect_url(self, *args, **kwargs): + self.object = get_object_or_404(Endpoint, pk=kwargs["pk"]) + if not self.object.assessment.can_edit_object(self.request.user): + raise PermissionDenied() if not self.object.assessment.bmd_settings.can_create_sessions: raise BadRequest("Assessment BMDS version is unsupported, can't create a new session.") obj = models.Session.create_new(self.object) @@ -64,7 +52,7 @@ class SessionList(BaseList): parent_template_name = "object" def get_queryset(self): - return self.model.objects.filter(endpoint=self.parent) + return super().get_queryset().filter(endpoint=self.parent) def _get_session_config(self, context) -> WebappConfig: diff --git a/hawc/apps/common/api/__init__.py b/hawc/apps/common/api/__init__.py index 860f1c3148..3cbc45a330 100644 --- a/hawc/apps/common/api/__init__.py +++ b/hawc/apps/common/api/__init__.py @@ -1,6 +1,4 @@ from .filters import * # noqa: F401,F403 from .mixins import * # noqa: F401,F403 -from .pagination import PaginationWithCount # noqa: F401,F403 -from .permissions import * # noqa: F401,F403 +from .pagination import * # noqa: F401,F403 from .throttling import * # noqa: F401,F403 -from .viewsets import * # noqa: F401,F403 diff --git a/hawc/apps/common/api/mixins.py b/hawc/apps/common/api/mixins.py index dcd25ec1b8..ee3313459c 100644 --- a/hawc/apps/common/api/mixins.py +++ b/hawc/apps/common/api/mixins.py @@ -1,7 +1,6 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import DataError -from django.shortcuts import get_object_or_404 from django.utils.encoding import force_str from rest_framework import status from rest_framework.exceptions import ValidationError as DrfValidationError @@ -109,16 +108,6 @@ def __init__(self, *args, **kwargs): self.fields.pop(field_name) -class LegacyAssessmentAdapterMixin: - """ - A mixin that allows API viewsets to interact with legacy methods. - """ - - def set_legacy_attr(self, pk): - self.parent = get_object_or_404(self.parent_model, pk=pk) - self.assessment = self.parent.get_assessment() - - class ReadWriteSerializerMixin: """ Class to be mixed into viewsets which enforces use of separate read/write serializers diff --git a/hawc/apps/common/api/pagination.py b/hawc/apps/common/api/pagination.py index 4217b44004..d3c41ce353 100644 --- a/hawc/apps/common/api/pagination.py +++ b/hawc/apps/common/api/pagination.py @@ -1,6 +1,10 @@ from rest_framework.pagination import PageNumberPagination +class DisabledPagination(PageNumberPagination): + page_size = None + + class PaginationWithCount(PageNumberPagination): def get_paginated_response(self, data): response = super().get_paginated_response(data) diff --git a/hawc/apps/common/api/permissions.py b/hawc/apps/common/api/permissions.py deleted file mode 100644 index 5b6a4e4e04..0000000000 --- a/hawc/apps/common/api/permissions.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.db import models -from rest_framework import exceptions, permissions - -from ...assessment.api import get_assessment_from_query - - -def user_can_edit_object( - instance: models.Model, user: models.Model, raise_exception: bool = False -) -> bool: - """Permissions check to ensure that user can edit assessment objects - - Args: - instance (models.Model): The instance to check - user (models.Model): The user instance - raise_exception (bool, optional): Throw an Exception; defaults to False. - - Raises: - exceptions.PermissionDenied: If raise_exc is True and user doesn't have permission - - """ - can_edit = instance.get_assessment().user_can_edit_object(user) - if raise_exception and not can_edit: - raise exceptions.PermissionDenied("Invalid permission to edit assessment.") - return can_edit - - -class CleanupFieldsPermissions(permissions.BasePermission): - """ - Custom permissions for bulk-cleanup view. No object-level permissions. Here we check that - the user has permission to edit content for this assessment, but not necessarily that they - can edit the specific ids selected. - """ - - def has_object_permission(self, request, view, obj): - # no object-specific permissions - return False - - def has_permission(self, request, view): - # must be team-member or higher to bulk-edit - view.assessment = get_assessment_from_query(request) - return view.assessment.user_can_edit_object(request.user) diff --git a/hawc/apps/common/api/viewsets.py b/hawc/apps/common/api/viewsets.py deleted file mode 100644 index a56d8057a0..0000000000 --- a/hawc/apps/common/api/viewsets.py +++ /dev/null @@ -1,112 +0,0 @@ -from django.db import models -from rest_framework import mixins, viewsets -from rest_framework.decorators import action -from rest_framework.response import Response - -from ...assessment.api import DisabledPagination -from ..exceptions import ClassConfigurationException -from ..views import bulk_create_object_log -from .filters import CleanupBulkIdFilter -from .mixins import ListUpdateModelMixin -from .permissions import CleanupFieldsPermissions, user_can_edit_object - - -class CleanupFieldsBaseViewSet( - ListUpdateModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet, -): - """ - Base Viewset for bulk updating text fields. - - Implements three routes: - - - GET /?assessment_id=1: list data available for cleanup - - PATCH /?assessment_id=1&ids=1,2,3: modify selected data - - GET /fields/: list fields available for cleanup - - Model should have a TEXT_CLEANUP_FIELDS class attribute which is list of fields. - For bulk update, 'X-CUSTOM-BULK-OPERATION' header must be provided. - Serializer should implement DynamicFieldsMixin. - """ - - model: models.Model = None # must be added - assessment_filter_args: str = "" # must be added - filter_backends = (CleanupBulkIdFilter,) - pagination_class = DisabledPagination - permission_classes = (CleanupFieldsPermissions,) - template_name = "assessment/endpointcleanup_list.html" - - def get_queryset(self): - return self.model.objects.all() - - @action(detail=False, methods=["get"]) - def fields(self, request, format=None): - """ - Return field names available for cleanup. - """ - cleanup_fields = self.model.TEXT_CLEANUP_FIELDS - TERM_FIELD_MAPPING = getattr(self.model, "TERM_FIELD_MAPPING", {}) - return Response( - {"text_cleanup_fields": cleanup_fields, "term_field_mapping": TERM_FIELD_MAPPING} - ) - - def partial_update_bulk(self, request, *args, **kwargs): - return super().partial_update_bulk(request, *args, **kwargs) - - def post_save_bulk(self, queryset, update_bulk_dict): - ids = list(queryset.values_list("id", flat=True)) - bulk_create_object_log("Updated", queryset, self.request.user.id) - queryset.model.delete_caches(ids) - - -class EditPermissionsCheckMixin: - """ - API Viewset mixin which provides permission checking during create/update/destroy operations. - - Fires "user_can_edit_object" checks during requests to create/update/destroy. Viewsets mixing - this in can define a variable "edit_check_keys", which is a list of serializer attribute - keys that should be used as the source for the checks. - """ - - def get_object_checks(self, serializer): - """ - Generates a list of model objects to check permissions against. Each object returned - can then be checked using user_can_edit_object, throwing an exception if necessary. - - Args: - serializer: the serializer of the associated viewset - - Returns: - List: A list of django model instances - """ - objects = [] - - # if thing already is created, check that we can edit it - if serializer.instance and serializer.instance.pk: - objects.append(serializer.instance) - - # additional checks on other attributes - for checker_key in getattr(self, "edit_check_keys", []): - if checker_key in serializer.validated_data: - objects.append(serializer.validated_data.get(checker_key)) - - # ensure we have at least one object to check - if len(objects) == 0: - raise ClassConfigurationException("Permission check required; nothing to check") - - return objects - - def perform_create(self, serializer): - for object_ in self.get_object_checks(serializer): - user_can_edit_object(object_, self.request.user, raise_exception=True) - super().perform_create(serializer) - - def perform_update(self, serializer): - for object_ in self.get_object_checks(serializer): - user_can_edit_object(object_, self.request.user, raise_exception=True) - super().perform_update(serializer) - - def perform_destroy(self, instance): - user_can_edit_object(instance, self.request.user, raise_exception=True) - super().perform_destroy(instance) diff --git a/hawc/apps/common/views.py b/hawc/apps/common/views.py index c8f268f53b..32afa1b274 100644 --- a/hawc/apps/common/views.py +++ b/hawc/apps/common/views.py @@ -1,14 +1,13 @@ -import abc import logging -from typing import Any, Callable, Iterable, Optional +from typing import Any, Callable, Iterable, Optional, Type from urllib.parse import urlparse import reversion from django.contrib import messages from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import login_required, user_passes_test -from django.core.exceptions import EmptyResultSet, PermissionDenied -from django.db import transaction +from django.core.exceptions import PermissionDenied +from django.db import models, transaction from django.forms.models import model_to_dict from django.http import HttpRequest, HttpResponseRedirect from django.shortcuts import get_object_or_404 @@ -17,8 +16,10 @@ from django.utils.decorators import method_decorator from django.utils.http import is_same_domain from django.views.generic import DetailView, ListView +from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import CreateView, DeleteView, UpdateView +from ..assessment.constants import AssessmentViewPermissions from ..assessment.models import Assessment, BaseEndpoint, Log, TimeSpentEditing from .crumbs import Breadcrumb from .filterset import BaseFilterSet @@ -185,6 +186,8 @@ class AssessmentPermissionsMixin: by the assessment object but not including the assessment object. """ + assessment_permission: AssessmentViewPermissions + def deny_for_locked_study(self, user, assessment, obj): # determine relevant study for a given object, and then checks its editable status. # If not set, raises a PermissionDenied. @@ -212,112 +215,79 @@ def check_study_editability(self, user, assessment, obj): else: return False - # could be a model element (Study, Endpoint, etc.) or a view to create a new one (EndpointCreate, etc.) - def get_contextual_object_for_study_editability_check(self): - if hasattr(self, "object") and self.object is not None: - # looking at a specific object directly - return self.object - elif hasattr(self, "parent") and self.parent is not None: - # looking at a Create view; can look at the parent/container object to determine study editable status - return self.parent - else: - return None + def get_object_for_study_editability_check(self): + # return object if one exists (detail, update, delete) + if object := getattr(self, "object", None): + return object - def permission_check_user_can_view(self): - logger.debug("Permissions checked") - if not self.assessment.user_can_view_object(self.request.user): - raise PermissionDenied + # return parent if one exists (create) + if parent := getattr(self, "parent", None): + return parent - def permission_check_user_can_edit(self): - logger.debug("Permissions checked") - if self.model == Assessment: - canEdit = self.assessment.user_can_edit_assessment(self.request.user) - else: - self.deny_for_locked_study( - self.request.user, - self.assessment, - self.get_contextual_object_for_study_editability_check(), - ) - canEdit = self.assessment.user_can_edit_object(self.request.user) - if not canEdit: - raise PermissionDenied + raise ValueError("Cannot determine permissions object") + + def check_queryset_study_editability(self, queryset): + first_object = queryset.first() + if first_object is None: + raise ValueError("Cannot determine if objects should be locked for editing") + self.deny_for_locked_study(self.request.user, self.assessment, first_object) def get_object(self, **kwargs): - """ - Check to make sure user can view object - """ - obj = kwargs.get("object") - if not obj: - obj = super().get_object(**kwargs) + obj = kwargs.get("object") or super().get_object(**kwargs) if not hasattr(self, "assessment"): self.assessment = obj.get_assessment() - if self.crud == "Read": - perms = self.assessment.user_can_view_object(self.request.user) - else: - if self.model == Assessment: - perms = self.assessment.user_can_edit_assessment(self.request.user) - else: - self.deny_for_locked_study(self.request.user, self.assessment, obj) - perms = self.assessment.user_can_edit_object(self.request.user) - - logger.debug("Permissions checked") - if perms: - return obj - else: - raise PermissionDenied + permission_checked = False + if self.assessment_permission is AssessmentViewPermissions.PROJECT_MANAGER: + permission_checked = self.assessment.user_can_edit_assessment(self.request.user) + elif self.assessment_permission is AssessmentViewPermissions.TEAM_MEMBER: + self.deny_for_locked_study(self.request.user, self.assessment, obj) + permission_checked = self.assessment.user_can_edit_object(self.request.user) + elif self.assessment_permission is AssessmentViewPermissions.VIEWER: + permission_checked = self.assessment.user_can_view_object(self.request.user) + + if not permission_checked: + raise PermissionDenied() + logger.debug("Permissions checked: object") + + return obj def get_queryset(self): - """ - IF attempting to use for permissions checking, requires a - self.assessment parameter in class with the assessment to check - permissions for. - """ queryset = super().get_queryset() - if not hasattr(self, "assessment"): - # get_object calls get_queryset; thus we must be careful to check - # the correct object + + # don't queryset if we have `get_object`; assume we check there + if isinstance(self, SingleObjectMixin): return queryset - else: - # IF, the view has a self.assessment identified, this will check - # to ensure that a user is allowed to perform the selected actions. - # - # TODO: might be preferred to check the get_assessment function with - # a user, and ensure that get_assessment is called for ALL models - # with assessment permissions mixin - # - if self.crud == "Read": - perms = self.assessment.user_can_view_object(self.request.user) - else: - if self.model == Assessment: - perms = self.assessment.user_can_edit_assessment(self.request.user) - else: - obj = queryset.first() - if obj is None: - raise EmptyResultSet( - "Cannot determine if objects should be locked for editing" - ) - self.deny_for_locked_study(self.request.user, self.assessment, obj) - perms = self.assessment.user_can_edit_object(self.request.user) - logger.debug("Permissions checked") - if perms: - return queryset - else: - raise PermissionDenied + + if not hasattr(self, "assessment"): + raise ValueError("No assessment object; required to check permission") + + permission_checked = False + if self.assessment_permission is AssessmentViewPermissions.PROJECT_MANAGER: + self.check_queryset_study_editability(queryset) + permission_checked = self.assessment.user_can_edit_assessment(self.request.user) + elif self.assessment_permission is AssessmentViewPermissions.TEAM_MEMBER: + self.check_queryset_study_editability(queryset) + permission_checked = self.assessment.user_can_edit_object(self.request.user) + elif self.assessment_permission is AssessmentViewPermissions.VIEWER: + permission_checked = self.assessment.user_can_view_object(self.request.user) + + if not permission_checked: + raise PermissionDenied() + logger.debug("Permissions checked: queryset)") + + return queryset def get_obj_perms(self): if not hasattr(self, "assessment"): - logger.error("unable to determine object permissions") - return {"view": False, "edit": False, "edit_assessment": False} + raise ValueError("Unable to determine object permissions") logger.debug("Permissions added") user_perms = self.assessment.user_permissions(self.request.user) - contextual_obj = self.get_contextual_object_for_study_editability_check() - study_perm_check = self.check_study_editability( - self.request.user, self.assessment, contextual_obj - ) + object = self.get_object_for_study_editability_check() + study_perm_check = self.check_study_editability(self.request.user, self.assessment, object) if study_perm_check is not None: user_perms["edit"] = study_perm_check @@ -343,88 +313,6 @@ def get_success_url(self): return response -class ProjectManagerOrHigherMixin: - """ - Mixin for project-manager access to page. - Requires a get_assessment method; checked for all HTTP verbs. - """ - - @abc.abstractmethod - def get_assessment(self, request, *args, **kwargs): - raise NotImplementedError("get_assessment requires implementation") - - def dispatch(self, request, *args, **kwargs): - self.assessment = self.get_assessment(request, *args, **kwargs) - logger.debug("Permissions checked") - if not self.assessment.user_can_edit_assessment(request.user): - raise PermissionDenied - return super().dispatch(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["assessment"] = self.assessment - return context - - -class TeamMemberOrHigherMixin: - """ - Mixin for team-member access to page. - Requires a get_assessment method; checked for all HTTP verbs. - """ - - @abc.abstractmethod - def get_assessment(self, request, *args, **kwargs): - raise NotImplementedError("get_assessment requires implementation") - - def dispatch(self, request, *args, **kwargs): - self.assessment = self.get_assessment(request, *args, **kwargs) - logger.debug("Permissions checked") - if not self.assessment.user_can_edit_object(request.user): - raise PermissionDenied - return super().dispatch(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["assessment"] = self.assessment - return context - - -class CanCreateMixin: - """ - Checks to make sure that the user has appropriate permissions before adding - a new object to the assessment. Requires a self.assessment variable to be - created before rendering. - """ - - def user_can_create_object(self, assessment): - """ - If person is superuser or assessment is editable and user is a project - manager or team member. - """ - logger.debug("Permissions checked") - if self.request.user.is_superuser: - return True - elif self.request.user.is_anonymous: - return False - else: - return (assessment.editable is True) and ( - (self.request.user in assessment.project_manager.all()) - or (self.request.user in assessment.team_members.all()) - ) - - def get(self, request, *args, **kwargs): - if self.user_can_create_object(self.assessment): - return super().get(request, *args, **kwargs) - else: - raise PermissionDenied - - def post(self, request, *args, **kwargs): - if self.user_can_create_object(self.assessment): - return super().post(request, *args, **kwargs) - else: - raise PermissionDenied - - class CopyAsNewSelectorMixin: copy_model = None # required @@ -475,6 +363,7 @@ def get_context_data(self, **kwargs): class BaseDetail(WebappMixin, AssessmentPermissionsMixin, DetailView): crud = "Read" breadcrumb_active_name: Optional[str] = None + assessment_permission = AssessmentViewPermissions.VIEWER def get_breadcrumbs(self) -> list[Breadcrumb]: return Breadcrumb.build_assessment_crumbs(self.request.user, self.object) @@ -487,8 +376,7 @@ def get_context_data(self, **kwargs): "breadcrumbs": self.get_breadcrumbs(), } for key, value in extras.items(): - if key not in kwargs: - kwargs[key] = value + kwargs.setdefault(key, value) context = super().get_context_data(**kwargs) if self.breadcrumb_active_name: context["breadcrumbs"].append(Breadcrumb(name=self.breadcrumb_active_name)) @@ -497,17 +385,26 @@ def get_context_data(self, **kwargs): class BaseDelete(WebappMixin, AssessmentPermissionsMixin, MessageMixin, DeleteView): crud = "Delete" + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER @transaction.atomic def delete(self, request, *args, **kwargs): self.object = self.get_object() - self.permission_check_user_can_edit() + self.check_delete() success_url = self.get_success_url() self.create_log(self.object) self.object.delete() self.send_message() return HttpResponseRedirect(success_url) + def check_delete(self): + """Additional permission checks for DELETE requests; not GET requests. + + This may be useful for situations where you need to explain to a user why they cannot + delete an object in the GET, and if they try, we can raise an exception here. + """ + pass + def create_log(self, obj): create_object_log("Deleted", obj, self.assessment.id, self.request.user.id) @@ -523,8 +420,7 @@ def get_context_data(self, **kwargs): "breadcrumbs": self.get_breadcrumbs(), } for key, value in extras.items(): - if key not in kwargs: - kwargs[key] = value + kwargs.setdefault(key, value) context = super().get_context_data(**kwargs) return context @@ -538,6 +434,7 @@ class BaseUpdate( WebappMixin, TimeSpentOnPageMixin, AssessmentPermissionsMixin, MessageMixin, UpdateView ): crud = "Update" + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER @transaction.atomic def form_valid(self, form): @@ -561,8 +458,7 @@ def get_context_data(self, **kwargs): "breadcrumbs": self.get_breadcrumbs(), } for key, value in extras.items(): - if key not in kwargs: - kwargs[key] = value + kwargs.setdefault(key, value) context = super().get_context_data(**kwargs) return context @@ -575,14 +471,14 @@ def get_breadcrumbs(self) -> list[Breadcrumb]: class BaseCreate( WebappMixin, TimeSpentOnPageMixin, AssessmentPermissionsMixin, MessageMixin, CreateView ): - parent_model = None # required - parent_template_name: Optional[str] = None # required + parent_model: Type[models.Model] + parent_template_name: str crud = "Create" + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def dispatch(self, *args, **kwargs): - self.parent = get_object_or_404(self.parent_model, pk=kwargs["pk"]) - self.assessment = self.parent.get_assessment() - self.permission_check_user_can_edit() + parent = get_object_or_404(self.parent_model, pk=kwargs["pk"]) + self.parent = self.get_object(object=parent) # call for assessment permissions check return super().dispatch(*args, **kwargs) def get_form_kwargs(self): @@ -611,8 +507,7 @@ def get_context_data(self, **kwargs): "breadcrumbs": self.get_breadcrumbs(), } for key, value in extras.items(): - if key not in kwargs: - kwargs[key] = value + kwargs.setdefault(key, value) context = super().get_context_data(**kwargs) context[self.parent_template_name] = self.parent return context @@ -646,11 +541,11 @@ class BaseList(WebappMixin, AssessmentPermissionsMixin, ListView): parent_template_name = None crud = "Read" breadcrumb_active_name: Optional[str] = None + assessment_permission = AssessmentViewPermissions.VIEWER def dispatch(self, *args, **kwargs): self.parent = get_object_or_404(self.parent_model, pk=kwargs["pk"]) self.assessment = self.parent.get_assessment() - self.permission_check_user_can_view() return super().dispatch(*args, **kwargs) def get_context_data(self, **kwargs): @@ -661,8 +556,7 @@ def get_context_data(self, **kwargs): "breadcrumbs": self.get_breadcrumbs(), } for key, value in extras.items(): - if key not in kwargs: - kwargs[key] = value + kwargs.setdefault(key, value) context = super().get_context_data(**kwargs) if self.parent_template_name: context[self.parent_template_name] = self.parent @@ -793,20 +687,17 @@ def get_context_data(self, **kwargs): class BaseFilterList(BaseList): - filterset_class: BaseFilterSet + filterset_class: Type[BaseFilterSet] paginate_by = 25 def get_paginate_by(self, qs) -> int: value = self.filterset.form.cleaned_data.get("paginate_by") return tryParseInt(value, default=self.paginate_by, min_value=10, max_value=500) - def get_base_queryset(self): - return self.model.objects.all() - def get_filterset_kwargs(self): return dict( data=self.request.GET, - queryset=self.get_base_queryset(), + queryset=super().get_queryset(), request=self.request, assessment=self.assessment, ) diff --git a/hawc/apps/epi/api.py b/hawc/apps/epi/api.py index 4648fe852a..a5f3df53a4 100644 --- a/hawc/apps/epi/api.py +++ b/hawc/apps/epi/api.py @@ -8,49 +8,59 @@ from rest_framework.response import Response from rest_framework.serializers import ValidationError -from ..assessment.api import AssessmentEditViewset, AssessmentLevelPermissions -from ..assessment.models import Assessment, DSSTox -from ..assessment.serializers import AssessmentSerializer -from ..common.api import ( +from ..assessment.api import ( + AssessmentEditViewset, + AssessmentLevelPermissions, CleanupFieldsBaseViewSet, - LegacyAssessmentAdapterMixin, - ReadWriteSerializerMixin, + EditPermissionsCheckMixin, ) -from ..common.api.viewsets import EditPermissionsCheckMixin +from ..assessment.constants import AssessmentViewSetPermissions +from ..assessment.models import Assessment, DSSTox +from ..assessment.serializers import AssessmentSerializer +from ..common.api import ReadWriteSerializerMixin from ..common.helper import FlatExport, re_digits from ..common.renderers import PandasRenderers from ..common.serializers import HeatmapQuerySerializer, UnusedSerializer -from ..common.views import AssessmentPermissionsMixin from . import exports, models, serializers from .actions.model_metadata import EpiAssessmentMetadata -class EpiAssessmentViewset( - AssessmentPermissionsMixin, LegacyAssessmentAdapterMixin, viewsets.GenericViewSet -): - parent_model = Assessment - model = models.Outcome +class EpiAssessmentViewset(viewsets.GenericViewSet): + model = Assessment + queryset = 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_outcome_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="export", renderer_classes=PandasRenderers) + return models.Outcome.objects.published(self.assessment) + return models.Outcome.objects.get_qs(self.assessment) + + @action( + detail=True, + url_path="export", + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def export(self, request, pk): """ Retrieve epidemiology data for assessment. """ - self.set_legacy_attr(pk) - self.permission_check_user_can_view() - exporter = exports.OutcomeComplete(self.get_queryset(), filename=f"{self.assessment}-epi") + self.get_object() + exporter = exports.OutcomeComplete( + self.get_outcome_queryset(), filename=f"{self.assessment}-epi" + ) 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). @@ -58,12 +68,11 @@ def study_heatmap(self, request, pk): 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.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}-epi-study-heatmap-pub-{unpublished}" df = cache.get(key) @@ -73,7 +82,12 @@ def study_heatmap(self, request, pk): export = FlatExport(df=df, filename=f"epi-study-heatmap-{self.assessment.id}") return Response(export) - @action(detail=True, url_path="result-heatmap", renderer_classes=PandasRenderers) + @action( + detail=True, + url_path="result-heatmap", + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def result_heatmap(self, request, pk): """ Return heatmap data for assessment, at the result level (one row per result). @@ -81,12 +95,11 @@ def result_heatmap(self, request, pk): 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.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}-epi-result-heatmap-pub-{unpublished}" df = cache.get(key) diff --git a/hawc/apps/epimeta/api.py b/hawc/apps/epimeta/api.py index f366eec09f..0371b1a5f1 100644 --- a/hawc/apps/epimeta/api.py +++ b/hawc/apps/epimeta/api.py @@ -3,39 +3,41 @@ from rest_framework.response import Response from ..assessment.api import AssessmentLevelPermissions, AssessmentViewset +from ..assessment.constants import AssessmentViewSetPermissions from ..assessment.models import Assessment -from ..common.api import LegacyAssessmentAdapterMixin from ..common.helper import re_digits from ..common.renderers import PandasRenderers from ..common.serializers import UnusedSerializer -from ..common.views import AssessmentPermissionsMixin from . import exports, models, serializers -class EpiMetaAssessmentViewset( - AssessmentPermissionsMixin, LegacyAssessmentAdapterMixin, viewsets.GenericViewSet -): - parent_model = Assessment - model = models.MetaResult +class EpiMetaAssessmentViewset(viewsets.GenericViewSet): + model = Assessment + queryset = 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_meta_result_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="export", renderer_classes=PandasRenderers) + return models.MetaResult.objects.published(self.assessment) + return models.MetaResult.objects.get_qs(self.assessment) + + @action( + detail=True, + url_path="export", + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def export(self, request, pk): """ Retrieve epidemiology metadata for assessment. """ - self.set_legacy_attr(pk) - self.permission_check_user_can_view() + self.get_object() exporter = exports.MetaResultFlatComplete( - self.get_queryset(), filename=f"{self.assessment}-epi-meta" + self.get_meta_result_queryset(), filename=f"{self.assessment}-epi-meta" ) return Response(exporter.build_export()) diff --git a/hawc/apps/epiv2/api.py b/hawc/apps/epiv2/api.py index 47e66c590f..8dad8044a5 100644 --- a/hawc/apps/epiv2/api.py +++ b/hawc/apps/epiv2/api.py @@ -2,9 +2,9 @@ from rest_framework.decorators import action from rest_framework.response import Response -from ..assessment.api import AssessmentEditViewset, BaseAssessmentViewset +from ..assessment.api import AssessmentEditViewset, BaseAssessmentViewset, EditPermissionsCheckMixin +from ..assessment.constants import AssessmentViewSetPermissions from ..assessment.models import Assessment -from ..common.api.viewsets import EditPermissionsCheckMixin from ..common.renderers import PandasRenderers from . import exports, models, serializers from .actions.model_metadata import EpiV2Metadata @@ -13,7 +13,12 @@ class EpiAssessmentViewset(BaseAssessmentViewset): model = Assessment - @action(detail=True, url_path="export", renderer_classes=PandasRenderers) + @action( + detail=True, + url_path="export", + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def export(self, request, pk): """ Retrieve epidemiology data for assessment. diff --git a/hawc/apps/invitro/api.py b/hawc/apps/invitro/api.py index 54b89bdf94..9720653297 100644 --- a/hawc/apps/invitro/api.py +++ b/hawc/apps/invitro/api.py @@ -6,36 +6,39 @@ AssessmentLevelPermissions, AssessmentRootedTagTreeViewset, AssessmentViewset, + CleanupFieldsBaseViewSet, ) +from ..assessment.constants import AssessmentViewSetPermissions from ..assessment.models import Assessment -from ..common.api import CleanupFieldsBaseViewSet, LegacyAssessmentAdapterMixin from ..common.helper import re_digits from ..common.renderers import PandasRenderers from ..common.serializers import UnusedSerializer -from ..common.views import AssessmentPermissionsMixin from . import exports, models, serializers -class IVAssessmentViewset( - AssessmentPermissionsMixin, LegacyAssessmentAdapterMixin, viewsets.GenericViewSet -): - parent_model = Assessment - model = models.IVEndpoint +class IVAssessmentViewset(viewsets.GenericViewSet): + model = Assessment + queryset = 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).order_by("id") - return self.model.objects.get_qs(self.assessment).order_by("id") - - @action(detail=True, url_path="full-export", renderer_classes=PandasRenderers) + return models.IVEndpoint.objects.published(self.assessment).order_by("id") + return models.IVEndpoint.objects.get_qs(self.assessment).order_by("id") + + @action( + detail=True, + url_path="full-export", + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def full_export(self, request, pk): - self.set_legacy_attr(pk) - self.permission_check_user_can_view() - self.object_list = self.get_queryset() + self.get_object() + self.object_list = self.get_endpoint_queryset() exporter = exports.DataPivotEndpoint( self.object_list, filename=f"{self.assessment}-invitro" ) diff --git a/hawc/apps/invitro/views.py b/hawc/apps/invitro/views.py index 7e35045cb6..f70628b308 100644 --- a/hawc/apps/invitro/views.py +++ b/hawc/apps/invitro/views.py @@ -1,7 +1,7 @@ from django.middleware.csrf import get_token from django.urls import reverse -from django.views.generic import DetailView +from ..assessment.constants import AssessmentViewPermissions from ..assessment.models import Assessment from ..common.crumbs import Breadcrumb from ..common.helper import WebappConfig @@ -13,8 +13,6 @@ BaseFilterList, BaseUpdate, BaseUpdateWithFormset, - ProjectManagerOrHigherMixin, - WebappMixin, ) from ..mgmt.views import EnsureExtractionStartedMixin from ..study.models import Study @@ -103,12 +101,10 @@ def get_success_url(self): # Endpoint categories -class EndpointCategoryUpdate(WebappMixin, ProjectManagerOrHigherMixin, DetailView): +class EndpointCategoryUpdate(BaseDetail): model = Assessment template_name = "invitro/ivendpointecategory_form.html" - - def get_assessment(self, request, *args, **kwargs): - return self.get_object() + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/hawc/apps/lit/api.py b/hawc/apps/lit/api.py index 309d81a2e8..f4b6f2282e 100644 --- a/hawc/apps/lit/api.py +++ b/hawc/apps/lit/api.py @@ -6,7 +6,7 @@ from django.core.cache import cache from django.db import transaction from django.utils import timezone -from rest_framework import exceptions, mixins, status, viewsets +from rest_framework import exceptions, mixins, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import ParseError, ValidationError from rest_framework.parsers import FileUploadParser @@ -16,14 +16,11 @@ METHODS_NO_PUT, AssessmentLevelPermissions, AssessmentRootedTagTreeViewset, -) -from ..assessment.models import Assessment -from ..common.api import ( CleanupFieldsBaseViewSet, - LegacyAssessmentAdapterMixin, - OncePerMinuteThrottle, - PaginationWithCount, ) +from ..assessment.constants import AssessmentViewSetPermissions +from ..assessment.models import Assessment +from ..common.api import OncePerMinuteThrottle, PaginationWithCount from ..common.helper import FlatExport, re_digits from ..common.renderers import PandasRenderers from ..common.serializers import UnusedSerializer @@ -31,10 +28,10 @@ from . import exports, models, serializers -class LiteratureAssessmentViewset(LegacyAssessmentAdapterMixin, viewsets.GenericViewSet): - parent_model = Assessment +class LiteratureAssessmentViewset(viewsets.GenericViewSet): model = Assessment permission_classes = (AssessmentLevelPermissions,) + action_perms = {} filterset_class = None serializer_class = UnusedSerializer lookup_value_regex = re_digits @@ -42,7 +39,11 @@ class LiteratureAssessmentViewset(LegacyAssessmentAdapterMixin, viewsets.Generic def get_queryset(self): return self.model.objects.all() - @action(detail=True, renderer_classes=PandasRenderers) + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def tags(self, request, pk): """ Show literature tags for entire assessment. @@ -52,7 +53,7 @@ def tags(self, request, pk): export = FlatExport(df=df, filename=f"reference-tags-{self.assessment.id}") return Response(export) - @action(detail=True, methods=("get", "post")) + @action(detail=True, methods=("get", "post"), permission_classes=(permissions.AllowAny,)) def tagtree(self, request, pk, *args, **kwargs): """ Get/Update literature tags for an assessment in tree-based structure @@ -74,7 +75,11 @@ def tagtree(self, request, pk, *args, **kwargs): ) return Response(serializer.data) - @action(detail=True, pagination_class=PaginationWithCount) + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + pagination_class=PaginationWithCount, + ) def references(self, request, pk): """ Get references for an assessment @@ -101,7 +106,12 @@ def references(self, request, pk): serializer = serializers.ReferenceSerializer(page, many=True) return self.get_paginated_response(serializer.data) - @action(detail=True, renderer_classes=PandasRenderers, url_path="reference-ids") + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + url_path="reference-ids", + ) def reference_ids(self, request, pk): """ Get literature reference ids for all assessment references @@ -116,26 +126,36 @@ def reference_ids(self, request, pk): detail=True, methods=("get", "post"), url_path="reference-tags", + permission_classes=(permissions.AllowAny,), renderer_classes=PandasRenderers, ) def reference_tags(self, request, pk): """ Apply reference tags for all references in an assessment. """ - instance = self.get_object() + assessment = self.get_object() + if self.request.method == "GET": + if not assessment.user_can_view_object(request.user): + raise exceptions.PermissionDenied() if self.request.method == "POST": + if not assessment.user_can_edit_object(request.user): + raise exceptions.PermissionDenied() serializer = serializers.BulkReferenceTagSerializer( - data=request.data, context={"assessment": instance} + data=request.data, context={"assessment": assessment} ) serializer.is_valid(raise_exception=True) serializer.bulk_create_tags() - df = models.ReferenceTags.objects.as_dataframe(instance.id) - export = FlatExport(df=df, filename=f"reference-tags-{self.assessment.id}") + df = models.ReferenceTags.objects.as_dataframe(assessment.id) + export = FlatExport(df=df, filename=f"reference-tags-{assessment.id}") return Response(export) - @action(detail=True, url_path="reference-year-histogram") + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + url_path="reference-year-histogram", + ) def reference_year_histogram(self, request, pk): instance = self.get_object() # get all the years for a given assessment @@ -172,7 +192,11 @@ def reference_year_histogram(self, request, pk): return Response(payload) - @action(detail=True, url_path="topic-model") + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + url_path="topic-model", + ) def topic_model(self, request, pk): assessment = self.get_object() if assessment.literature_settings.has_topic_model: @@ -181,11 +205,14 @@ def topic_model(self, request, pk): data = {"status": "No topic model available"} return Response(data) - @action(detail=True, methods=("post",), url_path="topic-model-request-refresh") + @action( + detail=True, + methods=("post",), + url_path="topic-model-request-refresh", + action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT, + ) def topic_model_request_refresh(self, request, pk): assessment = self.get_object() - if not assessment.user_can_edit_object(request.user): - raise exceptions.PermissionDenied() assessment.literature_settings.topic_tsne_refresh_requested = timezone.now() assessment.literature_settings.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -193,6 +220,7 @@ def topic_model_request_refresh(self, request, pk): @action( detail=True, url_path="references-download", + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, renderer_classes=PandasRenderers, ) def references_download(self, request, pk): @@ -211,7 +239,12 @@ def references_download(self, request, pk): ) return Response(exporter.build_export()) - @action(detail=True, renderer_classes=PandasRenderers, url_path="tag-heatmap") + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + url_path="tag-heatmap", + ) def tag_heatmap(self, request, pk): """ Get tags formatted in a long format desireable for heatmaps. @@ -231,6 +264,7 @@ def tag_heatmap(self, request, pk): throttle_classes=(OncePerMinuteThrottle,), methods=("post",), url_path="replace-hero", + action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT, ) def replace_hero(self, request, pk): """Replace old HERO ID with new HERO ID for selected references @@ -255,6 +289,7 @@ def replace_hero(self, request, pk): throttle_classes=(OncePerMinuteThrottle,), methods=("post",), url_path="update-reference-metadata-from-hero", + action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT, ) def update_reference_metadata_from_hero(self, request, pk): """ @@ -271,6 +306,7 @@ def update_reference_metadata_from_hero(self, request, pk): @action( detail=True, methods=("post",), + action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT, parser_classes=(FileUploadParser,), renderer_classes=PandasRenderers, url_path="excel-to-json", @@ -299,12 +335,17 @@ class SearchViewset(mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets model = models.Search serializer_class = serializers.SearchSerializer permission_classes = (AssessmentLevelPermissions,) + action_perms = {} lookup_value_regex = re_digits def get_queryset(self): return self.model.objects.all() - @action(detail=True, renderer_classes=PandasRenderers) + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def references(self, request, pk): """ Return all references for a given Search @@ -324,7 +365,11 @@ class ReferenceFilterTagViewset(AssessmentRootedTagTreeViewset): model = models.ReferenceFilterTag serializer_class = serializers.ReferenceFilterTagSerializer - @action(detail=True, renderer_classes=PandasRenderers) + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def references(self, request, pk): """ Return all references for a selected tag, including tag-descendants. @@ -365,9 +410,12 @@ class ReferenceViewset( http_method_names = METHODS_NO_PUT serializer_class = serializers.ReferenceSerializer permission_classes = (AssessmentLevelPermissions,) + action_perms = {} queryset = models.Reference.objects.all() - @action(detail=True, methods=("post",)) + @action( + detail=True, methods=("post",), action_perms=AssessmentViewSetPermissions.CAN_EDIT_OBJECT + ) def tag(self, request, pk): response = {"status": "fail"} ref = self.get_object() diff --git a/hawc/apps/lit/serializers.py b/hawc/apps/lit/serializers.py index d9fed5d492..148dc7b274 100644 --- a/hawc/apps/lit/serializers.py +++ b/hawc/apps/lit/serializers.py @@ -47,7 +47,6 @@ def validate(self, data): user = self.context["request"].user if not data["assessment"].user_can_edit_object(user): - # TODO - move authentication check outside validation? raise exceptions.PermissionDenied("Invalid permissions to edit assessment") # set slug value based on title; assert it's unique diff --git a/hawc/apps/lit/views.py b/hawc/apps/lit/views.py index c04f912cec..dceea0b38a 100644 --- a/hawc/apps/lit/views.py +++ b/hawc/apps/lit/views.py @@ -8,26 +8,14 @@ from django.shortcuts import get_object_or_404 from django.template import loader from django.urls import reverse, reverse_lazy -from django.views.generic import DetailView, TemplateView -from django.views.generic.edit import FormView +from django.views.generic import TemplateView +from ..assessment.constants import AssessmentViewPermissions from ..assessment.models import Assessment from ..common.crumbs import Breadcrumb from ..common.filterset import dynamic_filterset from ..common.helper import WebappConfig, listToUl, tryParseInt -from ..common.views import ( - AssessmentPermissionsMixin, - BaseCreate, - BaseDelete, - BaseDetail, - BaseFilterList, - BaseList, - BaseUpdate, - MessageMixin, - ProjectManagerOrHigherMixin, - TeamMemberOrHigherMixin, - WebappMixin, -) +from ..common.views import BaseCreate, BaseDelete, BaseDetail, BaseFilterList, BaseList, BaseUpdate from . import constants, filterset, forms, models @@ -48,7 +36,9 @@ class LitOverview(BaseList): breadcrumb_active_name = "Literature review" def get_queryset(self): - return self.model.objects.filter(assessment=self.assessment).exclude(slug="manual-import") + return ( + super().get_queryset().filter(assessment=self.assessment).exclude(slug="manual-import") + ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -70,16 +60,15 @@ def get_context_data(self, **kwargs): return context -class SearchCopyAsNewSelector(TeamMemberOrHigherMixin, FormView): +class SearchCopyAsNewSelector(BaseDetail): """ Select an existing search and copy-as-new """ + model = Assessment template_name = "lit/search_copy_selector.html" form_class = forms.SearchSelectorForm - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(Assessment, pk=self.kwargs.get("pk")) + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -244,7 +233,7 @@ def get(self, request, *args, **kwargs): return HttpResponseRedirect(self.object.get_absolute_url()) -class TagReferences(WebappMixin, TeamMemberOrHigherMixin, FormView): +class TagReferences(BaseDetail): """ Abstract base-class to tag references, using various methods to get instance. """ @@ -252,6 +241,7 @@ class TagReferences(WebappMixin, TeamMemberOrHigherMixin, FormView): model = Assessment form_class = forms.TagReferenceForm template_name = "lit/reference_tag.html" + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def get_ref_qs_filters(self) -> dict: raise NotImplementedError("Subclass requires implementation") @@ -287,12 +277,6 @@ class TagBySearch(TagReferences): model = models.Search - def get_assessment(self, request, *args, **kwargs): - self.object = get_object_or_404( - self.model, slug=self.kwargs.get("slug"), assessment=self.kwargs.get("pk") - ) - return self.object.get_assessment() - def get_ref_qs_filters(self): return dict(searches=self.object) @@ -310,10 +294,6 @@ class TagByReference(TagReferences): model = models.Reference - def get_assessment(self, request, *args, **kwargs): - self.object = get_object_or_404(self.model, pk=self.kwargs.get("pk")) - return self.object.get_assessment() - def get_ref_qs_filters(self): return dict(pk=self.object.pk) @@ -331,10 +311,6 @@ class TagByTag(TagReferences): model = models.ReferenceFilterTag - def get_assessment(self, request, *args, **kwargs): - self.object = get_object_or_404(self.model, pk=self.kwargs.get("pk")) - return self.object.get_assessment() - def get_ref_qs_filters(self): return dict(tags=self.object.pk) @@ -351,10 +327,6 @@ class TagByUntagged(TagReferences): model = Assessment - def get_assessment(self, request, *args, **kwargs): - self.object = get_object_or_404(Assessment, id=self.kwargs.get("pk")) - return self.object - def get_ref_qs_filters(self): return dict(tags=self.object.pk) @@ -513,7 +485,7 @@ def get_app_config(self, context) -> WebappConfig: ) -class RefUploadExcel(ProjectManagerOrHigherMixin, MessageMixin, FormView): +class RefUploadExcel(BaseUpdate): """ Upload Excel files and update reference details. """ @@ -521,9 +493,7 @@ class RefUploadExcel(ProjectManagerOrHigherMixin, MessageMixin, FormView): model = Assessment template_name = "lit/reference_upload_excel.html" form_class = forms.ReferenceExcelUploadForm - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.model, pk=kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER def get_form_kwargs(self): kwargs = super().get_form_kwargs() @@ -553,15 +523,13 @@ def get_success_url(self): return reverse_lazy("lit:overview", args=[self.assessment.pk]) -class RefListExtract(TeamMemberOrHigherMixin, MessageMixin, FormView): +class RefListExtract(BaseUpdate): template_name = "lit/reference_extract_list.html" breadcrumb_active_name = "Prepare for extraction" model = Assessment form_class = forms.BulkReferenceStudyExtractForm success_message = "Selected references were successfully converted to studies." - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.model, pk=kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def get_form_kwargs(self): kwargs = super().get_form_kwargs() @@ -629,10 +597,7 @@ class RefDelete(BaseDelete): def get_success_url(self): return reverse_lazy("lit:overview", args=(self.assessment.pk,)) - def permission_check_user_can_edit(self): - # perform standard check - super().permission_check_user_can_edit() - # and additional check + def check_delete(self): if self.object.has_study: raise PermissionDenied("Cannot delete - object has related study") @@ -683,7 +648,7 @@ def get_context_data(self, **kwargs): return context -class TagsUpdate(WebappMixin, ProjectManagerOrHigherMixin, DetailView): +class TagsUpdate(BaseDetail): """ Update tags for an assessment. Note that right now, only project managers of the assessment can update tags. (we use the Assessment as the model in an @@ -692,9 +657,7 @@ class TagsUpdate(WebappMixin, ProjectManagerOrHigherMixin, DetailView): model = Assessment template_name = "lit/tags_update.html" - - def get_assessment(self, request, *args, **kwargs): - return self.get_object() + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -726,13 +689,11 @@ def get_app_config(self, context) -> WebappConfig: ) -class LiteratureAssessmentUpdate(ProjectManagerOrHigherMixin, BaseUpdate): +class LiteratureAssessmentUpdate(BaseUpdate): success_message = "Literature assessment settings updated." model = models.LiteratureAssessment form_class = forms.LiteratureAssessmentForm - - def get_assessment(self, request, *args, **kwargs): - return self.get_object().assessment + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -743,7 +704,7 @@ def get_success_url(self): return reverse_lazy("lit:tags_update", args=(self.assessment.id,)) -class TagsCopy(AssessmentPermissionsMixin, MessageMixin, FormView): +class TagsCopy(BaseUpdate): """ Remove exiting tags and copy all tags from a separate assessment. """ @@ -752,15 +713,10 @@ class TagsCopy(AssessmentPermissionsMixin, MessageMixin, FormView): template_name = "lit/tags_copy.html" form_class = forms.TagsCopyForm success_message = "Literature tags for this assessment have been updated" - - def dispatch(self, *args, **kwargs): - self.assessment = get_object_or_404(Assessment, pk=kwargs["pk"]) - self.permission_check_user_can_edit() - return super().dispatch(*args, **kwargs) + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["assessment"] = self.assessment context["breadcrumbs"] = lit_overview_crumbs( self.request.user, self.assessment, "Copy tags" ) @@ -780,12 +736,10 @@ def get_success_url(self): return reverse_lazy("lit:tags_update", kwargs={"pk": self.assessment.pk}) -class BulkTagReferences(TeamMemberOrHigherMixin, BaseDetail): +class BulkTagReferences(BaseDetail): model = Assessment template_name = "lit/bulk_tag_references.html" - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.model, pk=kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/hawc/apps/mgmt/api.py b/hawc/apps/mgmt/api.py index ff9d013870..7ab999047b 100644 --- a/hawc/apps/mgmt/api.py +++ b/hawc/apps/mgmt/api.py @@ -1,10 +1,10 @@ -from django.shortcuts import get_object_or_404 from rest_framework import permissions from rest_framework.decorators import action from rest_framework.response import Response -from ..assessment.api import AssessmentEditViewset, AssessmentLevelPermissions, DisabledPagination -from ..assessment.models import Assessment +from ..assessment.api import AssessmentEditViewset, AssessmentLevelPermissions +from ..assessment.constants import AssessmentViewSetPermissions +from ..common.api import DisabledPagination from . import models, serializers @@ -12,17 +12,18 @@ class TaskViewSet(AssessmentEditViewset): http_method_names = ["get", "patch", "head", "options", "trace"] assessment_filter_args = "study__assessment" model = models.Task + list_actions = ["list", "assessment_assignments"] serializer_class = serializers.TaskSerializer permission_classes = ( - AssessmentLevelPermissions, permissions.IsAuthenticated, + AssessmentLevelPermissions, ) pagination_class = DisabledPagination def get_queryset(self): return super().get_queryset().select_related("owner", "study", "study__assessment") - @action(detail=False) + @action(detail=False, permission_classes=(permissions.IsAuthenticated,)) def assignments(self, request): # Tasks assigned to user. qs = self.model.objects.owned_by(request.user).select_related( @@ -31,13 +32,12 @@ def assignments(self, request): serializer = serializers.TaskByAssessmentSerializer(qs, many=True) return Response(serializer.data) - @action(detail=True, methods=["get"]) - def assessment_assignments(self, request, pk=None): + @action(detail=False, action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT) + def assessment_assignments(self, request): # Tasks assigned to user for a specific assessment - assessment = get_object_or_404(Assessment, pk=pk) qs = ( self.model.objects.owned_by(request.user) - .filter(study__assessment=assessment) + .filter(study__assessment=self.assessment) .select_related("owner", "study", "study__reference_ptr", "study__assessment") ) serializer = serializers.TaskByAssessmentSerializer(qs, many=True) diff --git a/hawc/apps/mgmt/views.py b/hawc/apps/mgmt/views.py index b986b2389b..981cebb379 100644 --- a/hawc/apps/mgmt/views.py +++ b/hawc/apps/mgmt/views.py @@ -1,13 +1,13 @@ from django.apps import apps from django.middleware.csrf import get_token -from django.shortcuts import get_object_or_404 from django.urls import reverse from django.views.generic import ListView +from ..assessment.constants import AssessmentViewPermissions from ..assessment.models import Assessment from ..common.crumbs import Breadcrumb from ..common.helper import WebappConfig -from ..common.views import BaseList, LoginRequiredMixin, TeamMemberOrHigherMixin, WebappMixin +from ..common.views import BaseList, LoginRequiredMixin, WebappMixin from ..study.serializers import StudyAssessmentSerializer from . import models @@ -64,7 +64,7 @@ def get_review_studies(self): def get_app_config(self, context) -> WebappConfig: assessment_id = self.assessment.id if hasattr(self, "assessment") else None task_url = ( - reverse("mgmt:api:task-assessment-assignments", args=(assessment_id,)) + reverse("mgmt:api:task-assessment-assignments") if assessment_id else reverse("mgmt:api:task-assignments") ) @@ -110,7 +110,9 @@ class UserAssessmentAssignments(RobTaskMixin, LoginRequiredMixin, BaseList): def get_queryset(self): return ( - self.model.objects.owned_by(self.request.user) + super() + .get_queryset() + .owned_by(self.request.user) .filter(study__assessment=self.assessment) .select_related("owner", "study", "study__reference_ptr", "study__assessment") ) @@ -128,16 +130,14 @@ def get_context_data(self, **kwargs): # Assessment-level task views -class TaskDashboard(TeamMemberOrHigherMixin, BaseList): +class TaskDashboard(BaseList): parent_model = Assessment model = models.Task template_name = "mgmt/assessment_dashboard.html" - - def get_assessment(self, *args, **kwargs): - return get_object_or_404(Assessment, pk=kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def get_queryset(self): - return self.model.objects.assessment_qs(self.assessment.id) + return super().get_queryset().filter(study__assessment_id=self.assessment.id) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/hawc/apps/riskofbias/api.py b/hawc/apps/riskofbias/api.py index b8f11f115b..e06c4ae003 100644 --- a/hawc/apps/riskofbias/api.py +++ b/hawc/apps/riskofbias/api.py @@ -13,21 +13,18 @@ AssessmentEditViewset, AssessmentLevelPermissions, AssessmentViewset, - DisabledPagination, + CleanupFieldsBaseViewSet, + CleanupFieldsPermissions, InAssessmentFilter, get_assessment_id_param, ) +from ..assessment.constants import AssessmentViewSetPermissions from ..assessment.models import Assessment, TimeSpentEditing -from ..common.api import ( - CleanupFieldsBaseViewSet, - CleanupFieldsPermissions, - LegacyAssessmentAdapterMixin, -) +from ..common.api import DisabledPagination from ..common.helper import re_digits, tryParseInt from ..common.renderers import PandasRenderers from ..common.serializers import UnusedSerializer from ..common.validators import validate_exact_ids -from ..common.views import AssessmentPermissionsMixin from ..mgmt.models import Task from ..riskofbias import exports from ..study.models import Study @@ -37,25 +34,22 @@ logger = logging.getLogger(__name__) -class RiskOfBiasAssessmentViewset( - AssessmentPermissionsMixin, LegacyAssessmentAdapterMixin, viewsets.GenericViewSet -): - parent_model = Assessment - model = Study +class RiskOfBiasAssessmentViewset(viewsets.GenericViewSet): + model = Assessment + queryset = 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() - if not perms["edit"]: - return self.model.objects.published(self.assessment) - return self.model.objects.get_qs(self.assessment.id) - - @action(detail=True, url_path="export", renderer_classes=PandasRenderers) + @action( + detail=True, + url_path="export", + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def export(self, request, pk): - self.set_legacy_attr(pk) - self.permission_check_user_can_view() + self.get_object() rob_name = self.assessment.get_rob_name_display().lower() exporter = exports.RiskOfBiasFlat( self.get_queryset().none(), @@ -65,10 +59,14 @@ def export(self, request, pk): return Response(exporter.build_export()) - @action(detail=True, url_path="full-export", renderer_classes=PandasRenderers) + @action( + detail=True, + url_path="full-export", + action_perms=AssessmentViewSetPermissions.TEAM_MEMBER_OR_HIGHER, + renderer_classes=PandasRenderers, + ) def full_export(self, request, pk): - self.set_legacy_attr(pk) - self.permission_check_user_can_view() + self.get_object() rob_name = self.assessment.get_rob_name_display().lower() exporter = exports.RiskOfBiasCompleteFlat( self.get_queryset().none(), @@ -84,10 +82,11 @@ def bulk_rob_copy(self, request): """ return BulkRobCopyAction.handle_request(request, atomic=True) - @action(detail=True, url_path="settings") + @action( + detail=True, url_path="settings", action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT + ) def rob_settings(self, request, pk): - self.set_legacy_attr(pk) - self.permission_check_user_can_view() + self.get_object() ser = serializers.AssessmentRiskOfBiasSerializer(self.assessment) return Response(ser.data) @@ -208,21 +207,23 @@ def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @action(detail=True, methods=["get"]) + @action(detail=True, methods=["get"], action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT) def override_options(self, request, pk=None): object_ = self.get_object() return Response(object_.get_override_options()) - @action(detail=False, methods=("post",)) + @action(detail=False, methods=("post",), permission_classes=[]) def create_v2(self, request): + # perms checked in serializer kw = {"context": self.get_serializer_context()} serializer = serializers.RiskOfBiasAssignmentSerializer(data=request.data, **kw) serializer.is_valid(raise_exception=True) self.perform_create(serializer) return Response(serializer.data, status=status.HTTP_201_CREATED) - @action(detail=True, methods=("patch",)) + @action(detail=True, methods=("patch",), permission_classes=[]) def update_v2(self, request, *args, **kwargs): + # perms checked in serializer instance = self.get_object() kw = {"context": self.get_serializer_context()} serializer = serializers.RiskOfBiasAssignmentSerializer( diff --git a/hawc/apps/riskofbias/views.py b/hawc/apps/riskofbias/views.py index 9bcb959a9a..2e110182b7 100644 --- a/hawc/apps/riskofbias/views.py +++ b/hawc/apps/riskofbias/views.py @@ -4,8 +4,8 @@ from django.middleware.csrf import get_token from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy -from django.views.generic.edit import FormView +from ..assessment.constants import AssessmentViewPermissions from ..assessment.models import Assessment from ..common.crumbs import Breadcrumb from ..common.helper import WebappConfig @@ -16,9 +16,6 @@ BaseFilterList, BaseList, BaseUpdate, - MessageMixin, - ProjectManagerOrHigherMixin, - TeamMemberOrHigherMixin, TimeSpentOnPageMixin, get_referrer, ) @@ -72,18 +69,15 @@ def get_app_config(self, context) -> WebappConfig: ) -class ARoBEdit(ProjectManagerOrHigherMixin, BaseDetail): +class ARoBEdit(BaseDetail): """ Displays a form for sorting and editing domain and metric. """ crud = "Update" - model = models.Assessment template_name = "riskofbias/arob_edit.html" - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.model, pk=kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -107,18 +101,13 @@ def get_app_config(self, context) -> WebappConfig: ) -class ARoBTextEdit(ProjectManagerOrHigherMixin, BaseUpdate): +class ARoBTextEdit(BaseUpdate): parent_model = Assessment model = models.RiskOfBiasAssessment template_name = "riskofbias/arob_text_form.html" form_class = forms.RobTextForm success_message = "Help text has been updated." - - def get_object(self, queryset=None): - return get_object_or_404(self.model, assessment_id=self.assessment.pk) - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.parent_model, pk=kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -129,15 +118,13 @@ def get_success_url(self): return reverse_lazy("riskofbias:arob_detail", kwargs={"pk": self.assessment.pk}) -class ARoBCopy(ProjectManagerOrHigherMixin, MessageMixin, FormView): +class ARoBCopy(BaseUpdate): model = models.RiskOfBiasDomain parent_model = Assessment template_name = "riskofbias/arob_copy.html" form_class = forms.RiskOfBiasCopyForm success_message = "Settings have been updated." - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.parent_model, pk=kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -207,16 +194,13 @@ def get_rob_assignment_data(assessment, studies): } -class RobAssignmentList(TeamMemberOrHigherMixin, BaseFilterList): +class RobAssignmentList(BaseFilterList): parent_model = Assessment model = Study template_name = "riskofbias/rob_assignment_list.html" paginate_by = 50 filterset_class = StudyFilterSet - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.parent_model, pk=kwargs["pk"]) - def get_filterset_kwargs(self): kwargs = super().get_filterset_kwargs() kwargs["include_rob_authors"] = True @@ -250,15 +234,13 @@ def get_app_config(self, context) -> WebappConfig: return WebappConfig(app="riskofbiasStartup", page="robAssignmentStartup", data=data) -class RobAssignmentUpdate(ProjectManagerOrHigherMixin, BaseFilterList): +class RobAssignmentUpdate(BaseFilterList): parent_model = Assessment model = Study template_name = "riskofbias/rob_assignment_update.html" filterset_class = StudyFilterSet paginate_by = 50 - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(self.parent_model, pk=kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.PROJECT_MANAGER def get_filterset_kwargs(self): kwargs = super().get_filterset_kwargs() @@ -448,16 +430,13 @@ def get_app_config(self, context) -> WebappConfig: return self.get_webapp_config("final") -class RoBsDetailAll(TeamMemberOrHigherMixin, RoBDetail): +class RoBsDetailAll(RoBDetail): """ Detailed view of all active risk of bias metric, including final. """ template_name = "riskofbias/rob_detail_all.html" - - def get_assessment(self, request, *args, **kwargs): - self.object = get_object_or_404(Study, pk=kwargs["pk"]) - return self.object.get_assessment() + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/hawc/apps/study/api.py b/hawc/apps/study/api.py index 17a32d4ed0..73dc502d24 100644 --- a/hawc/apps/study/api.py +++ b/hawc/apps/study/api.py @@ -8,26 +8,24 @@ from ..assessment.api import ( AssessmentLevelPermissions, - DisabledPagination, + CleanupFieldsBaseViewSet, InAssessmentFilter, - get_assessment_id_param, ) +from ..assessment.constants import AssessmentViewSetPermissions from ..assessment.models import Assessment -from ..common.api import CleanupFieldsBaseViewSet +from ..common.api import DisabledPagination from ..common.helper import re_digits from ..common.views import create_object_log from ..riskofbias.serializers import RiskOfBiasSerializer from . import models, serializers -class Study( - mixins.CreateModelMixin, - viewsets.ReadOnlyModelViewSet, -): +class Study(mixins.CreateModelMixin, viewsets.ReadOnlyModelViewSet): assessment_filter_args = "assessment" model = models.Study pagination_class = DisabledPagination permission_classes = (AssessmentLevelPermissions,) + action_perms = {} filter_backends = (InAssessmentFilter, DjangoFilterBackend) list_actions = ["list", "rob_scores"] lookup_value_regex = re_digits @@ -51,13 +49,12 @@ def get_queryset(self): "riskofbiases__scores__overridden_objects__content_object", ).select_related("assessment") - @action(detail=False) + @action(detail=False, action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT) def rob_scores(self, request): - assessment_id = get_assessment_id_param(request) - scores = self.model.objects.rob_scores(assessment_id) + scores = self.model.objects.rob_scores(self.assessment.pk) return Response(scores) - @action(detail=False) + @action(detail=False, permission_classes=[]) def types(self, request): study_types = self.model.STUDY_TYPE_FIELDS return Response(study_types) @@ -76,15 +73,19 @@ def perform_create(self, serializer): self.request.user.id, ) - @action(detail=True, url_path="all-rob") + @action( + detail=True, + url_path="all-rob", + action_perms=AssessmentViewSetPermissions.TEAM_MEMBER_OR_HIGHER, + ) def rob(self, request, pk: int): study = self.get_object() - if not self.assessment.user_is_team_member_or_higher(self.request.user): - raise PermissionDenied("You must be part of the team to view unpublished data") serializer = RiskOfBiasSerializer(study.get_active_robs(), many=True) return Response(serializer.data) - @action(detail=False, methods=("post",), url_path="create-from-identifier") + @action( + detail=False, methods=("post",), url_path="create-from-identifier", permission_classes=[] + ) def create_from_identifier(self, request): # check permissions assessment = get_object_or_404(Assessment, id=request.data.get("assessment_id", -1)) diff --git a/hawc/apps/study/views.py b/hawc/apps/study/views.py index 4ae063d5b3..e4d34eb754 100644 --- a/hawc/apps/study/views.py +++ b/hawc/apps/study/views.py @@ -95,7 +95,7 @@ class StudyRead(BaseDetail): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - attachments_viewable = self.assessment.user_is_part_of_team(self.request.user) + attachments_viewable = self.assessment.user_is_reviewer_or_higher(self.request.user) context["config"] = { "studyContent": self.object.get_json(json_encode=False), "attachments_viewable": attachments_viewable, @@ -168,7 +168,7 @@ class AttachmentRead(BaseDetail): def get(self, request, *args, **kwargs): self.object = self.get_object() - if self.assessment.user_is_part_of_team(self.request.user): + if self.assessment.user_is_reviewer_or_higher(self.request.user): return HttpResponseRedirect(self.object.attachment.url) else: raise PermissionDenied diff --git a/hawc/apps/summary/api.py b/hawc/apps/summary/api.py index 60406a3d13..752a575450 100644 --- a/hawc/apps/summary/api.py +++ b/hawc/apps/summary/api.py @@ -10,11 +10,12 @@ AssessmentEditViewset, AssessmentLevelPermissions, AssessmentViewset, - DisabledPagination, + EditPermissionsCheckMixin, InAssessmentFilter, ) +from ..assessment.constants import AssessmentViewSetPermissions from ..assessment.models import Assessment -from ..common.api import EditPermissionsCheckMixin +from ..common.api import DisabledPagination from ..common.helper import re_digits from ..common.renderers import DocxRenderer, PandasRenderers from ..common.serializers import UnusedSerializer @@ -32,7 +33,7 @@ def filter_queryset(self, request, queryset, view): self.instance = get_object_or_404(queryset.model, **view.kwargs) view.assessment = self.instance.get_assessment() - if not view.assessment.user_is_part_of_team(request.user): + if not view.assessment.user_is_reviewer_or_higher(request.user): queryset = queryset.filter(published=True) return queryset @@ -41,13 +42,18 @@ class SummaryAssessmentViewset(viewsets.GenericViewSet): parent_model = Assessment model = Assessment permission_classes = (AssessmentLevelPermissions,) + action_perms = {} serializer_class = UnusedSerializer lookup_value_regex = re_digits def get_queryset(self): return self.model.objects.all() - @action(detail=True, url_path="visual-heatmap-datasets") + @action( + detail=True, + url_path="visual-heatmap-datasets", + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + ) def heatmap_datasets(self, request, pk): """Returns a list of the heatmap datasets available for an assessment.""" instance = self.get_object() @@ -76,7 +82,11 @@ def get_serializer_class(self): cls = serializers.CollectionDataPivotSerializer return cls - @action(detail=True, renderer_classes=PandasRenderers) + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=PandasRenderers, + ) def data(self, request, pk): obj = self.get_object() export = obj.get_dataset() @@ -125,13 +135,17 @@ class SummaryTableViewset(AssessmentEditViewset): serializer_class = serializers.SummaryTableSerializer list_actions = ["list", "data"] - @action(detail=True, renderer_classes=(DocxRenderer,)) + @action( + detail=True, + action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT, + renderer_classes=(DocxRenderer,), + ) def docx(self, request, pk): obj = self.get_object() report = obj.to_docx(base_url=request._current_scheme_host) return Response(report) - @action(detail=False) + @action(detail=False, action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT) def data(self, request): ser = table_serializers.SummaryTableDataSerializer( data=request.query_params.dict(), context=self.get_serializer_context() diff --git a/hawc/apps/summary/table_serializers.py b/hawc/apps/summary/table_serializers.py index e99fb75315..7c42e119b4 100644 --- a/hawc/apps/summary/table_serializers.py +++ b/hawc/apps/summary/table_serializers.py @@ -113,7 +113,8 @@ def validate(self, data): if ( "request" in self.context and data["published_only"] is False - and data["assessment_id"].user_is_part_of_team(self.context["request"].user) is False + and data["assessment_id"].user_is_reviewer_or_higher(self.context["request"].user) + is False ): raise serializers.ValidationError( {"published_only": "Must be part of team to view unpublished data."} diff --git a/hawc/apps/summary/views.py b/hawc/apps/summary/views.py index 992fa0e9a5..e0073550b8 100644 --- a/hawc/apps/summary/views.py +++ b/hawc/apps/summary/views.py @@ -7,20 +7,13 @@ from django.middleware.csrf import get_token from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy -from django.views.generic import FormView, RedirectView, TemplateView +from django.views.generic import RedirectView, TemplateView +from ..assessment.constants import AssessmentViewPermissions from ..assessment.models import Assessment from ..common.crumbs import Breadcrumb from ..common.helper import WebappConfig -from ..common.views import ( - BaseCreate, - BaseDelete, - BaseDetail, - BaseFilterList, - BaseList, - BaseUpdate, - TeamMemberOrHigherMixin, -) +from ..common.views import BaseCreate, BaseDelete, BaseDetail, BaseFilterList, BaseList, BaseUpdate from ..riskofbias.models import RiskOfBiasMetric from . import constants, filterset, forms, models, serializers @@ -49,7 +42,7 @@ class SummaryTextList(BaseList): def get_queryset(self): rt = self.model.get_assessment_root_node(self.assessment.id) - return self.model.objects.filter(pk__in=[rt.pk]) + return super().get_queryset().filter(pk__in=[rt.pk]) def get_app_config(self, context) -> WebappConfig: return WebappConfig( @@ -179,13 +172,11 @@ def get_app_config(self, context) -> WebappConfig: ) -class SummaryTableCopy(TeamMemberOrHigherMixin, FormView): +class SummaryTableCopy(BaseUpdate): template_name = "summary/copy_selector.html" model = Assessment form_class = forms.SummaryTableCopySelectorForm - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(Assessment, pk=self.kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def get_form_kwargs(self): kwargs = super().get_form_kwargs() @@ -463,13 +454,11 @@ def get_context_data(self, **kwargs): return context -class VisualizationCopy(TeamMemberOrHigherMixin, FormView): +class VisualizationCopy(BaseUpdate): template_name = "summary/copy_selector.html" model = Assessment form_class = forms.VisualSelectorForm - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(Assessment, pk=self.kwargs["pk"]) + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def get_form_kwargs(self): kwargs = super().get_form_kwargs() @@ -613,14 +602,12 @@ def get_context_data(self, **kwargs): return context -class DataPivotCopyAsNewSelector(TeamMemberOrHigherMixin, FormView): +class DataPivotCopyAsNewSelector(BaseUpdate): # Select an existing assessed outcome as a template for a new one model = Assessment template_name = "summary/copy_selector.html" form_class = forms.DataPivotSelectorForm - - def get_assessment(self, request, *args, **kwargs): - return get_object_or_404(Assessment, pk=self.kwargs.get("pk")) + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER def get_form_kwargs(self): kwargs = super().get_form_kwargs() diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 45ef5e431b..4ddd59f0e2 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -589,11 +589,11 @@ def test_lit_reference_tags(self): assert isinstance(response, pd.DataFrame) def test_lit_import_reference_tags(self): - csv = "reference_id,tag_id\n5,14" + csv = "reference_id,tag_id\n1,2" client = HawcClient(self.live_server_url) client.authenticate("pm@hawcproject.org", "pw") response = client.lit.import_reference_tags( - assessment_id=self.db_keys.assessment_final, csv=csv + assessment_id=self.db_keys.assessment_working, csv=csv ) assert isinstance(response, pd.DataFrame) @@ -678,14 +678,22 @@ def test_replace_hero(self): # RiskOfBiasClient tests # ########################## - def test_riskofbias_data(self): + def test_riskofbias_export(self): client = HawcClient(self.live_server_url) - response = client.riskofbias.data(self.db_keys.assessment_client) + response = client.riskofbias.export(self.db_keys.assessment_client) assert isinstance(response, pd.DataFrame) - def test_riskofbias_full_data(self): + def test_riskofbias_full_export(self): client = HawcClient(self.live_server_url) - response = client.riskofbias.full_data(self.db_keys.assessment_client) + + # permission denied + with pytest.raises(HawcClientException) as err: + client.riskofbias.full_export(self.db_keys.assessment_client) + assert err.status_code == 403 + + # successful response + client.authenticate("team@hawcproject.org", "pw") + response = client.riskofbias.full_export(self.db_keys.assessment_client) assert isinstance(response, pd.DataFrame) def test_riskofbias_create(self): diff --git a/tests/hawc/apps/animal/test_serializer.py b/tests/hawc/apps/animal/test_serializer.py index 31328ee30a..bcee94fd59 100644 --- a/tests/hawc/apps/animal/test_serializer.py +++ b/tests/hawc/apps/animal/test_serializer.py @@ -12,6 +12,14 @@ from hawc.apps.myuser.models import HAWCUser +@pytest.fixture +def user_request(): + rf = RequestFactory() + request = rf.post("/") + request.user = HAWCUser.objects.get(email="team@hawcproject.org") + return request + + @pytest.mark.django_db class TestExperimentSerializer: def _get_valid_dataset(self, db_keys): @@ -32,29 +40,29 @@ def _get_valid_dataset(self, db_keys): ) return data - def test_success(self, db_keys): + def test_success(self, db_keys, user_request): data = self._get_valid_dataset(db_keys) - serializer = ExperimentSerializer(data=data) + serializer = ExperimentSerializer(data=data, context={"request": user_request}) assert serializer.is_valid() - def test_dtxsid_validator(self, db_keys): + def test_dtxsid_validator(self, db_keys, user_request): data = self._get_valid_dataset(db_keys) # should be valid with no dtxsid data.pop("dtxsid", None) - serializer = ExperimentSerializer(data=data) + serializer = ExperimentSerializer(data=data, context={"request": user_request}) assert serializer.is_valid() assert "dtxsid" not in serializer.validated_data # should be valid with a valid, existing dtxsid data["dtxsid"] = DSSTox.objects.first().dtxsid - serializer = ExperimentSerializer(data=data) + serializer = ExperimentSerializer(data=data, context={"request": user_request}) assert serializer.is_valid() assert "dtxsid" in serializer.validated_data # should be invalid with an invalid dtxsid data["dtxsid"] = "invalid" - serializer = ExperimentSerializer(data=data) + serializer = ExperimentSerializer(data=data, context={"request": user_request}) assert serializer.is_valid() is False assert "dtxsid" not in serializer.validated_data @@ -136,11 +144,7 @@ def test_dose_group_failures(self): @pytest.mark.django_db class TestEndpointSerializer: - def test_valid_requests_with_terms(self, db_keys): - rf = RequestFactory() - request = rf.post("/") - request.user = HAWCUser.objects.get(email="team@hawcproject.org") - + def test_valid_requests_with_terms(self, db_keys, user_request): # valid request with one term data = { "name": "Endpoint name", @@ -150,7 +154,7 @@ def test_valid_requests_with_terms(self, db_keys): "response_units": "μg/dL", "system_term": 1, } - serializer = EndpointSerializer(data=data, context={"request": request}) + serializer = EndpointSerializer(data=data, context={"request": user_request}) assert serializer.is_valid() assert serializer.validated_data["system"] == "Cardiovascular" @@ -164,7 +168,7 @@ def test_valid_requests_with_terms(self, db_keys): "system_term": 1, "organ_term": 2, } - serializer = EndpointSerializer(data=data, context={"request": request}) + serializer = EndpointSerializer(data=data, context={"request": user_request}) assert serializer.is_valid() assert serializer.validated_data["system"] == "Cardiovascular" assert serializer.validated_data["organ"] == "Serum" @@ -177,15 +181,11 @@ def test_valid_requests_with_terms(self, db_keys): "response_units": "μg/dL", "name_term": 5, } - serializer = EndpointSerializer(data=data, context={"request": request}) + serializer = EndpointSerializer(data=data, context={"request": user_request}) assert serializer.is_valid() assert serializer.validated_data["name"] == "Fatty Acid Balance" - def test_bad_requests_with_terms(self, db_keys): - rf = RequestFactory() - request = rf.post("/") - request.user = HAWCUser.objects.get(email="team@hawcproject.org") - + def test_bad_requests_with_terms(self, db_keys, user_request): # term_field or text_field is required data = { "animal_group_id": 1, @@ -193,7 +193,7 @@ def test_bad_requests_with_terms(self, db_keys): "variance_type": 1, "response_units": "μg/dL", } - serializer = EndpointSerializer(data=data, context={"request": request}) + serializer = EndpointSerializer(data=data, context={"request": user_request}) assert serializer.is_valid() is False assert serializer.errors == {"name": ["'name' or 'name_term' is required."]} @@ -206,6 +206,6 @@ def test_bad_requests_with_terms(self, db_keys): "response_units": "μg/dL", "system_term": 2, } - serializer = EndpointSerializer(data=data, context={"request": request}) + serializer = EndpointSerializer(data=data, context={"request": user_request}) assert serializer.is_valid() is False assert serializer.errors == {"system_term": ["Got term type '2', expected type '1'."]} diff --git a/tests/hawc/apps/common/test_api.py b/tests/hawc/apps/common/test_api.py index 596f1c8e5b..fea40e4a06 100644 --- a/tests/hawc/apps/common/test_api.py +++ b/tests/hawc/apps/common/test_api.py @@ -6,7 +6,7 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.test import APIClient -from hawc.apps.common.api import user_can_edit_object +from hawc.apps.assessment.api import user_can_edit_object from hawc.apps.common.diagnostics import worker_healthcheck from hawc.apps.myuser.models import HAWCUser from hawc.apps.study.models import Study diff --git a/tests/hawc/apps/riskofbias/test_api.py b/tests/hawc/apps/riskofbias/test_api.py index 9d42d55fc8..a889fbdec2 100644 --- a/tests/hawc/apps/riskofbias/test_api.py +++ b/tests/hawc/apps/riskofbias/test_api.py @@ -17,52 +17,58 @@ @pytest.mark.django_db class TestRiskOfBiasAssessmentViewset: - def test_permissions(self, db_keys): + def test_full_export(self, rewrite_data_files: bool, db_keys): + # permission check + anon_client = APIClient() rev_client = APIClient() assert rev_client.login(username="reviewer@hawcproject.org", password="pw") is True - anon_client = APIClient() + tm_client = APIClient() + assert tm_client.login(username="team@hawcproject.org", password="pw") is True - urls = [ - reverse("riskofbias:api:assessment-export", args=(db_keys.assessment_working,)), - reverse("riskofbias:api:assessment-full-export", args=(db_keys.assessment_working,)), - ] - for url in urls: - assert anon_client.get(url).status_code == 403 - assert rev_client.get(url).status_code == 200 + url = reverse("riskofbias:api:assessment-full-export", args=(db_keys.assessment_working,)) + assert anon_client.get(url).status_code == 403 + assert rev_client.get(url).status_code == 403 + assert tm_client.get(url).status_code == 200 - def test_full_export(self, rewrite_data_files: bool, db_keys): fn = Path(DATA_ROOT / "api-rob-assessment-full-export.json") url = ( reverse("riskofbias:api:assessment-full-export", args=(db_keys.assessment_final,)) + "?format=json" ) - client = APIClient() - resp = client.get(url) + # check data + resp = tm_client.get(url) assert resp.status_code == 200 - data = resp.json() - if rewrite_data_files: Path(fn).write_text(json.dumps(data, indent=2, sort_keys=True)) assert data == json.loads(fn.read_text()) def test_export(self, rewrite_data_files: bool, db_keys): + # permission check + anon_client = APIClient() + rev_client = APIClient() + assert rev_client.login(username="reviewer@hawcproject.org", password="pw") is True + tm_client = APIClient() + assert tm_client.login(username="team@hawcproject.org", password="pw") is True + + url = reverse("riskofbias:api:assessment-full-export", args=(db_keys.assessment_working,)) + assert anon_client.get(url).status_code == 403 + assert rev_client.get(url).status_code == 403 + assert tm_client.get(url).status_code == 200 + + # data check fn = Path(DATA_ROOT / "api-rob-assessment-export.json") url = ( reverse("riskofbias:api:assessment-export", args=(db_keys.assessment_final,)) + "?format=json" ) - client = APIClient() - resp = client.get(url) + resp = anon_client.get(url) assert resp.status_code == 200 - data = resp.json() - if rewrite_data_files: Path(fn).write_text(json.dumps(data, indent=2, sort_keys=True)) - assert data == json.loads(fn.read_text()) def test_PandasXlsxRenderer(self, db_keys): @@ -493,26 +499,6 @@ def test_rob_scores(self, db_keys): "score_symbol": "++", } - # TODO: evaluate how to correctly add header - - # # patch - # url = reverse("riskofbias:api:scores-list") + assessment_query + f"&ids={data['id']}" - # resp = c.patch( - # url, - # {"score": 16, "notes": "

More content here.

"}, - # headers={"X-CUSTOM-BULK-OPERATION": "true"}, - # format="json", - # ) - # assert resp.status_code == 201 - - # # ensure patch went through - # resp = c.get(detail_url, format="json") - # data = resp.json() - # assert resp.status_code == 200 - # assert data["id"] == 1 - # assert data["score"] == 16 - # assert data["notes"] == "

More content here.

" - @pytest.mark.django_db class TestRobAssignmentApi: