""" 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 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() print("\n\n\n Data: \n") print(type(user.exercise_groups)) user_groups = [GroupSerializer(group) for group in user.exercise_groups.all()] 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)