-
Jakob Dieterle authoredJakob Dieterle authored
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)