Skip to content
Snippets Groups Projects
common_views.py 16.15 KiB
""" All API views that are used to retrieve data from the database. They
can be categorized by the permissions they require. All views require a
user to be authenticated and most are only accessible by one user group """
import logging

import os

import constance
import django.contrib.auth.password_validation as validators
import nbformat
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core import exceptions
from django.db.models import Avg
from nbconvert import HTMLExporter
from rest_framework import generics, mixins, status, viewsets
from rest_framework.decorators import (throttle_classes,
                                       action)
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle

from core import models
from core.models import (ExamType, StudentInfo,
                         SubmissionType, TutorSubmissionAssignment, Group)
from core.permissions import IsReviewer, IsStudent, IsTutorOrReviewer, SolutionsEnabledToStudents
from core.serializers import (ExamSerializer, StudentInfoSerializer,
                              StudentInfoForListViewSerializer,
                              StudentSubmissionWithSolutionSerializer,
                              SubmissionNoTypeSerializer, StudentSubmissionSerializer,
                              SubmissionTypeSerializer, CorrectorSerializer,
                              UserAccountSerializer, SolutionCommentSerializer,
                              SubmissionNoTypeWithStudentSerializer,
                              GroupSerializer)

log = logging.getLogger(__name__)
config = constance.config


class StudentSelfApiView(generics.RetrieveAPIView):
    """ Gets all data that belongs to one student """
    permission_classes = (IsStudent,)
    serializer_class = StudentInfoSerializer

    def get_object(self) -> StudentInfo:
        """ The object in question is the student associated with the requests
        user. Since the permission IsStudent is satisfied the member exists """
        if self.request.user.is_superuser:
            return StudentInfo.objects.last()
        return self.request.user.student


class StudentSelfSubmissionsApiView(generics.ListAPIView):
    permission_classes = (IsStudent,)

    def get_serializer_class(self):
        if config.SHOW_SOLUTION_TO_STUDENTS:
            return StudentSubmissionWithSolutionSerializer

        return StudentSubmissionSerializer

    def get_queryset(self):
        return self.request.user.student.submissions


class StudentReviewerApiViewSet(viewsets.ReadOnlyModelViewSet):
    """ Gets a list of all students without individual submissions """
    permission_classes = (IsTutorOrReviewer,)
    serializer_class = StudentInfoForListViewSerializer

    def get_queryset(self):
        queryset = StudentInfo.objects \
            .select_related('user') \
            .select_related('exam') \
            .prefetch_related('submissions') \
            .prefetch_related('submissions__feedback') \
            .prefetch_related('submissions__type') \
            .all()

        if self.request.user.is_reviewer():
            return queryset

        elif self.request.user.is_tutor() and config.EXERCISE_MODE:
            return queryset.filter(
                user__exercise_groups__in=self.request.user.exercise_groups.all()
            )

        else:
            return []

    def _set_students_active(self, active):
        for student in self.get_queryset():
            user = student.user
            user.is_active = active
            user.save()

    @action(detail=False, methods=['post'], permission_classes=(IsReviewer, ))
    def deactivate(self, request):
        self._set_students_active(False)
        return Response(status=status.HTTP_200_OK)

    @action(detail=False, methods=['post'], permission_classes=(IsReviewer, ))
    def activate(self, request):
        self._set_students_active(True)
        return Response(status=status.HTTP_200_OK)


class ExamApiViewSet(viewsets.ReadOnlyModelViewSet):
    """ Gets a list of an individual exam by Id if provided """
    permission_classes = (IsReviewer,)
    queryset = ExamType.objects.all()
    serializer_class = ExamSerializer


class CorrectorApiViewSet(
        mixins.RetrieveModelMixin,
        mixins.UpdateModelMixin,
        mixins.CreateModelMixin,
        mixins.DestroyModelMixin,
        mixins.ListModelMixin,
        viewsets.GenericViewSet):
    """ Api endpoint for creating, listing, viewing or deleting tutors """
    permission_classes = (IsReviewer,)
    queryset = models.UserAccount.corrector \
        .with_feedback_count() \
        .prefetch_related('assignments')
    serializer_class = CorrectorSerializer

    @action(detail=False, methods=['post'], permission_classes=[AllowAny])
    @throttle_classes([AnonRateThrottle])
    def register(self, request):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        if serializer.validated_data.get('is_active', False):
            raise PermissionDenied(detail='Cannot be created active')

        registration_password = request.data.get('registration_password', None)
        if registration_password is None or registration_password != config.REGISTRATION_PASSWORD:
            raise PermissionDenied(detail='Invalid registration password')

        serializer.save()
        return Response(serializer.data, status=status.HTTP_201_CREATED)


class SubmissionTypeApiView(viewsets.ReadOnlyModelViewSet):
    """ Gets a list or a detail view of a single SubmissionType """
    queryset = SubmissionType.objects.all()
    serializer_class = SubmissionTypeSerializer
    permission_classes = [IsTutorOrReviewer | SolutionsEnabledToStudents]

    @action(detail=False)
    def available(self, request, *args, **kwargs):
        """
        GET Endpoint to fetch available counts for SubmissionTypes. Can be queried
        by group using the ?group query_parameter

        :return: Response with dictionary that contains available counts for each SubmissionType
        """

        group_param = request.query_params.get('group', None)
        group = Group.objects.filter(pk=group_param).first()
        sub_types = self.get_queryset()
        res = {}
        for sub_type in sub_types:
            counts_for_type = {}
            for stage, _ in models.TutorSubmissionAssignment.stages:
                counts_in_stage = TutorSubmissionAssignment.objects.available_assignments({
                    'stage': stage,
                    'submission_type': sub_type.pk,
                    'owner': self.request.user,
                    'group': None if not group else group.pk
                }).count()
                counts_for_type[str(stage)] = counts_in_stage
            res[str(sub_type.pk)] = counts_for_type

        return Response(res)


class SolutionCommentApiViewSet(
        mixins.CreateModelMixin,
        mixins.UpdateModelMixin,
        mixins.DestroyModelMixin,
        viewsets.GenericViewSet):
    permission_classes = (IsTutorOrReviewer,)
    queryset = models.SolutionComment.objects.all()
    serializer_class = SolutionCommentSerializer

    def destroy(self, request, *args, **kwargs):
        instance = self.get_object()
        if not request.user.is_reviewer() and instance.of_user != request.user:
            raise PermissionDenied(detail="You can only delete comments you made")
        self.perform_destroy(instance)
        return Response(status=status.HTTP_204_NO_CONTENT)

    def update(self, request, *args, **kwargs):
        instance = self.get_object()
        if instance.of_user != request.user:
            raise PermissionDenied(detail="You can only update comments you made")
        return super().update(request, *args, **kwargs)


class StatisticsEndpoint(viewsets.ViewSet):
    permission_classes = (IsTutorOrReviewer,)

    def list(self, request, *args, **kwargs):
        first_sub_type = models.SubmissionType.objects.first()

        return Response({
            'submissions_per_type':
                first_sub_type.submissions.count() if first_sub_type is not None else 0,

            'submissions_per_student':
                models.SubmissionType.objects.count(),

            'current_mean_score':
                models.Feedback.objects.aggregate(avg=Avg('score')).get('avg', 0),

            'submission_type_progress':
            # Queryset is explicitly evaluated so camelizer plugin camelizes it
                list(models.SubmissionType.get_annotated_feedback_count().values(
                    'pk',
                    'name',
                    'submission_count',
                    'feedback_final',
                    'feedback_in_validation',
                    'feedback_in_conflict'))
        })


class SubmissionViewSet(viewsets.ReadOnlyModelViewSet):
    permission_classes = (IsTutorOrReviewer,)

    def get_serializer_class(self):
        if self.request.user.is_reviewer() or config.EXERCISE_MODE:
            # this contains student fullname
            # in most cases a pseudonym, but useful for
            # tracking students across views in the frontend
            return SubmissionNoTypeWithStudentSerializer
        return SubmissionNoTypeSerializer

    def get_queryset(self):
        base_queryset = models.Submission.objects \
            .select_related('type') \
            .select_related('feedback') \
            .prefetch_related('tests') \
            .prefetch_related('feedback__feedback_lines') \
            .prefetch_related('feedback__feedback_lines__of_tutor') \
            .all()

        if self.request.user.is_reviewer() \
                or (self.request.user.is_tutor() and config.EXERCISE_MODE):
            return base_queryset
        elif self.request.user.is_student():
            return base_queryset.filter(
                student__user=self.request.user
            )
        else:
            return base_queryset.filter(
                assignments__owner=self.request.user
            )

    @action(detail=True, )
    def source_code(self, request, *args, **kwargs):
        submission = self.get_object()
        if submission.source_code_available:
            return Response(data={'source_code': submission.source_code})
        return Response(status=status.HTTP_404_NOT_FOUND)

    @action(detail=True, permission_classes=(IsStudent,))
    def html(self, request, *args, **kwargs):
        submission = self.get_object()
        if submission.type.programming_language == models.SubmissionType.PYTHON and \
                submission.source_code_available:
            notebook = nbformat.reads(submission.source_code, as_version=4)
            html_exporter = HTMLExporter()
            body, _ = html_exporter.from_notebook_node(notebook)
            return Response(body, content_type='text/html')
        return Response(status=status.HTTP_404_NOT_FOUND)


class UserAccountViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = UserAccountSerializer
    queryset = models.UserAccount.objects.all()

    @action(detail=True, methods=['patch'], permission_classes=(IsTutorOrReviewer,))
    def change_password(self, request, *args, **kwargs):
        user = self.get_object()
        if request.user != user and not request.user.is_reviewer():
            return Response(status=status.HTTP_403_FORBIDDEN)
        old_password = request.data.get('old_password')

        # tutors must always provide their current password
        # reviewers must provide their current password when they change
        # their own, not if they change the password of a tutor
        if (request.user.is_tutor() or
            request.user.is_reviewer and request.user == user) \
            and \
            (old_password is None or
             not check_password(old_password, user.password)):
            return Response(status=status.HTTP_401_UNAUTHORIZED)

        new_password = request.data.get('new_password')
        # validate password
        try:
            if new_password is not None:
                validators.validate_password(password=new_password, user=user)
        except exceptions.ValidationError as err:
            return Response({'new_password': list(err.messages)},
                            status=status.HTTP_406_NOT_ACCEPTABLE)
        user.set_password(new_password)
        user.save()
        log.info(f"User {request.user} changed password of {user}")
        return Response(status=status.HTTP_200_OK)

    @action(detail=True, methods=['patch'])
    def change_active(self, request, *args, **kwargs):
        active = request.data.get('is_active')
        req_user = request.user
        user = self.get_object()
        if active is None:
            error_msg = "You need to provide an 'active' field"
            return Response({'Error': error_msg}, status.HTTP_400_BAD_REQUEST)
        if req_user.is_reviewer() and req_user == user:
            error_msg = "As a reviewer, you cannot revoke your own access."
            return Response({'Error': error_msg}, status.HTTP_403_FORBIDDEN)
        if (req_user.is_student() or req_user.is_tutor()) and req_user != user:
            return Response(status.HTTP_403_FORBIDDEN)
        user.is_active = active
        user.save()
        return Response(status.HTTP_200_OK)

    @action(detail=True, methods=['patch'], permission_classes=(IsReviewer,))
    def change_groups(self, request, *args, **kwargs):
        print("\n data: ")
        print(request.data)
        print("\n")
        print(type("hi"))
        # for some reason only the newly added groups come as a group object
        groups = [x.get('pk') if type(x) is not str else x for x in request.data]
        req_user = request.user
        user = self.get_object()
        if groups is None:
            error_msg = "You need to provide an 'groups' field"
            return Response({'Error': error_msg}, status.HTTP_400_BAD_REQUEST)
        if req_user.is_student() or req_user.is_tutor():
            return Response(status.HTTP_403_FORBIDDEN)
        user.set_groups(groups)
        user.save()
        return Response(status.HTTP_200_OK)

    @action(detail=True)
    def get_groups(self, request, *args, **kwargs):
        req_user = request.user
        if req_user.is_student() or req_user.is_tutor():
            return Response(status.HTTP_403_FORBIDDEN)
        user = self.get_object()
        return Response(user.exercise_groups, status=status.HTTP_200_OK)

    @action(detail=False)
    def me(self, request):
        serializer = self.get_serializer(request.user)
        return Response(serializer.data, status=status.HTTP_200_OK)


class InstanceConfigurationViewSet(viewsets.ViewSet):
    @action(detail=False, methods=['patch'])
    def change_config(self, request):
        """
        PATCH Endpoint to modify constance settings. Requires reviewer permissions.

        :return: Response with dictionary of all modified constance fields.
        """
        if not self.request.user.is_reviewer():
            return Response(status=status.HTTP_403_FORBIDDEN)

        res = {}
        for key in request.data:

            # capitalize key and check if it is a valid constance entry
            caps_key = key.upper()
            if getattr(config, caps_key, None) is None:
                return Response(
                    f"{key} is not a valid setting.",
                    status=status.HTTP_409_CONFLICT
                )

            val = request.data[key]
            setattr(config, caps_key, val)
            res[key] = val

        return Response(res, status=status.HTTP_206_PARTIAL_CONTENT)

    def list(self, request):
        """
        GET Endpoint to list constance settings as well as additional config values.
        Constance settings will be supplied in the "instance_settings" field.

        :return: Response with dictionary of all settings and config values.
        """

        # construct constance data, lowercase the key so that it is correctly camel-cased
        settings_dict = {key.lower(): getattr(config, key) for key in dir(config)}
        res = {
            'timeDelta': settings.JWT_AUTH['JWT_EXPIRATION_DELTA'].seconds * 1000,
            'version': os.environ.get('VERSION'),
            'instanceSettings': settings_dict,
        }

        return Response(res, status=status.HTTP_200_OK)