diff --git a/course/api.py b/course/api.py index 279045fc7..d35481c06 100644 --- a/course/api.py +++ b/course/api.py @@ -24,16 +24,19 @@ THE SOFTWARE. """ -from django import http from django.core.exceptions import PermissionDenied from django.shortcuts import get_object_or_404 -from course.auth import with_course_api_auth, APIError -from course.constants import ( - participation_permission as pperm, - ) +from rest_framework.decorators import api_view +from rest_framework.response import Response +from rest_framework import status +from course.auth import with_course_api_auth, APIError from course.models import FlowSession +from course.constants import participation_permission as pperm +from course.serializers import ( + FlowSessionSerializer, FlowPageDateSerializer, FlowPageVisitSerializer, + FlowPageVisitGradeSerializer) # {{{ mypy @@ -44,45 +47,17 @@ # }}} -def flow_session_to_json(sess): - # type: (FlowSession) -> Any - last_activity = sess.last_activity() - return dict( - id=sess.id, - participation_username=( - sess.participation.user.username - if sess.participation is not None - else None), - participation_institutional_id=( - sess.participation.user.institutional_id - if sess.participation is not None - else None), - active_git_commit_sha=sess.active_git_commit_sha, - flow_id=sess.flow_id, - - start_time=sess.start_time.isoformat(), - completion_time=sess.completion_time, - last_activity_time=( - last_activity.isoformat() - if last_activity is not None - else None), - page_count=sess.page_count, - - in_progress=sess.in_progress, - access_rules_tag=sess.access_rules_tag, - expiration_mode=sess.expiration_mode, - points=sess.points, - max_points=sess.max_points, - result_comment=sess.result_comment, - ) - - +@api_view(["GET"]) @with_course_api_auth("Token") def get_flow_sessions(api_ctx, course_identifier): - # type: (APIContext, Text) -> http.HttpResponse + # type: (APIContext, Text) -> Response if not api_ctx.has_permission(pperm.view_gradebook): - raise PermissionDenied("token role does not have required permissions") + return Response( + exception=PermissionDenied( + "token role does not have required permissions"), + status=status.HTTP_403_FORBIDDEN + ) try: flow_id = api_ctx.request.GET["flow_id"] @@ -93,17 +68,22 @@ def get_flow_sessions(api_ctx, course_identifier): course=api_ctx.course, flow_id=flow_id) - result = [flow_session_to_json(sess) for sess in sessions] + result = [FlowSessionSerializer(sess).data for sess in sessions] - return http.JsonResponse(result, safe=False) + return Response(result) +@api_view(["GET"]) @with_course_api_auth("Token") def get_flow_session_content(api_ctx, course_identifier): - # type: (APIContext, Text) -> http.HttpResponse + # type: (APIContext, Text) -> Response if not api_ctx.has_permission(pperm.view_gradebook): - raise PermissionDenied("token role does not have required permissions") + return Response( + exception=PermissionDenied( + "token role does not have required permissions"), + status=status.HTTP_403_FORBIDDEN + ) try: session_id_str = api_ctx.request.GET["flow_session_id"] @@ -115,8 +95,11 @@ def get_flow_session_content(api_ctx, course_identifier): flow_session = get_object_or_404(FlowSession, id=session_id) if flow_session.course != api_ctx.course: - raise PermissionDenied( - "session's course does not match auth context") + return Response( + exception=PermissionDenied( + "session's course does not match auth context"), + status=status.HTTP_403_FORBIDDEN + ) from course.content import get_course_repo from course.flow import adjust_flow_session_page_data, assemble_answer_visits @@ -138,15 +121,7 @@ def get_flow_session_content(api_ctx, course_identifier): assert i == page_data.page_ordinal - page_data_json = dict( - ordinal=i, - page_type=page_data.page_type, - group_id=page_data.group_id, - page_id=page_data.page_id, - page_data=page_data.data, - title=page_data.title, - bookmarked=page_data.bookmarked, - ) + page_data_json = FlowPageDateSerializer(page_data).data answer_json = None grade_json = None @@ -174,27 +149,17 @@ def get_flow_session_content(api_ctx, course_identifier): norm_answer = [answer_file_ext, b64encode(norm_bytes_answer).decode("utf-8")] - answer_json = dict( - visit_time=visit.visit_time.isoformat(), - remote_address=repr(visit.remote_address), - user=visit.user.username if visit.user is not None else None, - impersonated_by=(visit.impersonated_by.username - if visit.impersonated_by is not None else None), - is_synthetic_visit=visit.is_synthetic, - answer_data=visit.answer, - answer=norm_answer, - ) + answer_json = FlowPageVisitSerializer(visit).data + answer_json.pop("flow_session") + answer_json.pop("page_data") + if norm_answer is not None: + answer_json["norm_answer"] = norm_answer grade = visit.get_most_recent_grade() if grade is not None: - grade_json = dict( - grader=(grade.grader.username - if grade.grader is not None else None), - grade_time=grade.grade_time.isoformat(), - graded_at_git_commit_sha=grade.graded_at_git_commit_sha, - max_points=grade.max_points, - correctness=grade.correctness, - feedback=grade.feedback) + grade_json = FlowPageVisitGradeSerializer(grade).data + grade_json.pop("visit") + grade_json.pop("grade_data") pages.append({ "page": page_data_json, @@ -203,11 +168,11 @@ def get_flow_session_content(api_ctx, course_identifier): }) result = { - "session": flow_session_to_json(flow_session), + "session": FlowSessionSerializer(flow_session).data, "pages": pages, } - return http.JsonResponse(result, safe=False) + return Response(result) # vim: foldmethod=marker diff --git a/course/serializers.py b/course/serializers.py new file mode 100644 index 000000000..42a34ea6a --- /dev/null +++ b/course/serializers.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- + +from __future__ import division + +__copyright__ = "Copyright (C) 2020 Dong Zhuang" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + + +from rest_framework import serializers +from course.models import ( + FlowSession, FlowPageVisit, FlowPageData, FlowPageVisitGrade) + + +class FlowSessionSerializer(serializers.ModelSerializer): + + class Meta: + model = FlowSession + + fields = ("id", + "username", + "institutional_id", + "active_git_commit_sha", + "flow_id", + "start_time", + "completion_time", + "last_activity", + "page_count", + "in_progress", + "access_rules_tag", + "expiration_mode", + "points", + "max_points", + "result_comment", + "points_percentage", + ) + + username = serializers.CharField( + source="participation.user.username", read_only=True) + + institutional_id = serializers.CharField( + source="participation.user.institutional_id", read_only=True) + + last_activity = serializers.SerializerMethodField() + + points_percentage = serializers.SerializerMethodField() + + def get_last_activity(self, obj): + return obj.last_activity() + + def get_points_percentage(self, obj): + return obj.points_percentage() + + +class FlowPageDateSerializer(serializers.ModelSerializer): + + class Meta: + model = FlowPageData + + fields = ("page_ordinal", + "page_type", + "group_id", + "page_id", + "data", + "title", + "bookmarked" + ) + + +class FlowPageVisitSerializer(serializers.ModelSerializer): + + class Meta: + model = FlowPageVisit + + fields = ("flow_session", + "page_data", + "visit_time", + "remote_address", + "user", + "impersonated_by", + "is_synthetic", + "answer", + "is_submitted_answer", + ) + + user = serializers.CharField(source="visitor.username", read_only=True) + impersonated_by = serializers.CharField( + source="impersonated_by.user.username", read_only=True) + + +class FlowPageVisitGradeSerializer(serializers.ModelSerializer): + + class Meta: + model = FlowPageVisitGrade + + fields = ( + "visit", + "grader", + "grade_time", + "graded_at_git_commit_sha", + "grade_data", + "max_points", + "correctness", + "feedback", + "percentage", + ) + + grader = serializers.CharField( + source="grader.username", read_only=True) + + percentage = serializers.SerializerMethodField() + + def get_percentage(self, obj): + if obj.correctness is not None: + return 100 * obj.correctness + else: + return None diff --git a/poetry.lock b/poetry.lock index ae1f4cfb6..a93464c4e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -364,6 +364,17 @@ reference = "c92d0373d12a02d1e52fb09b44010f156111d7ea" type = "git" url = "https://github.com/bakatrouble/django-yamlfield.git" +[[package]] +category = "main" +description = "Web APIs for Django, made easy." +name = "djangorestframework" +optional = false +python-versions = ">=3.5" +version = "3.11.0" + +[package.dependencies] +django = ">=1.11" + [[package]] category = "main" description = "pysaml2 integration for Django" @@ -1430,7 +1441,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "13c7b736cf3b2cc4d145cddb73c2eef021f01f5c94235facdb41d6713970554d" +content-hash = "8716d87dd56b325c3e56a8233f640a5f8a405e2cfd370247dfeb0e866cac3d29" python-versions = "^3.6" [metadata.files] @@ -1637,6 +1648,10 @@ django-select2 = [ {file = "django_select2-7.4.2-py2.py3-none-any.whl", hash = "sha256:06531d563ce33c3133682ae2bb9e6d762103a863d0054ffef51bae8b4cfcca6c"}, ] django-yamlfield = [] +djangorestframework = [ + {file = "djangorestframework-3.11.0-py3-none-any.whl", hash = "sha256:05809fc66e1c997fd9a32ea5730d9f4ba28b109b9da71fccfa5ff241201fd0a4"}, + {file = "djangorestframework-3.11.0.tar.gz", hash = "sha256:e782087823c47a26826ee5b6fa0c542968219263fb3976ec3c31edab23a4001f"}, +] djangosaml2 = [ {file = "djangosaml2-0.19.1-py2.py3-none-any.whl", hash = "sha256:2881813f00ea78dac4f38c14838ae90662d0781b911925c6373b900145a0e392"}, {file = "djangosaml2-0.19.1.tar.gz", hash = "sha256:7c6b3cceeb2022d15c3205e3a0f3f96c969b3a447a1fcfdb6c78c048994a186d"}, diff --git a/pyproject.toml b/pyproject.toml index fa341035d..d444c7d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ nbconvert = "^5.2.1" IPython = "^7.15.0" # For relate script colorama = "*" +djangorestframework = "^3.11.0" [tool.poetry.dev-dependencies] codecov = "^2.1.4" diff --git a/tests/test_api.py b/tests/test_api.py index 8a37b52e3..14925972b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -20,10 +20,18 @@ THE SOFTWARE. """ +import pytz +from datetime import datetime + from django.test import TestCase +from course.serializers import ( + FlowSessionSerializer, FlowPageDateSerializer, FlowPageVisitSerializer, + FlowPageVisitGradeSerializer +) + from tests.base_test_mixins import ( - SingleCourseQuizPageTestMixin, APITestMixin + SingleCourseQuizPageTestMixin, APITestMixin, SingleCourseTestMixin ) from tests import factories @@ -139,4 +147,45 @@ def test_fail_course_not_matched(self): self.assertEqual(resp.status_code, 403) +class FlowSessionSerializerTest(SingleCourseTestMixin, TestCase): + def test_serializer(self): + fs = factories.FlowSessionFactory(points=20, max_points=40) + fpdata = factories.FlowPageDataFactory(flow_session=fs) + serializer = FlowSessionSerializer(fs) + self.assertIsNone(serializer.data["last_activity"]) + + factories.FlowPageVisitFactory( + page_data=fpdata, answer={"answer": "hi"}, + visit_time=datetime(2018, 12, 31, tzinfo=pytz.UTC) + ) + + factories.FlowPageVisitFactory( + page_data=fpdata, answer={"answer": "hi2"}, + visit_time=datetime(2019, 1, 1, tzinfo=pytz.UTC) + ) + + serializer = FlowSessionSerializer(fs) + self.assertEqual(serializer.data["last_activity"].year, 2019) + + +class FlowPageDateSerializerTest(SingleCourseTestMixin, TestCase): + def test_serializer(self): + fpd = factories.FlowPageDataFactory() + serializer = FlowPageDateSerializer(fpd) + self.assertIsNotNone(serializer.data) + + +class FlowPageVisitSerializerTest(SingleCourseTestMixin, TestCase): + def test_serializer(self): + fpv = factories.FlowPageVisitFactory() + serializer = FlowPageVisitSerializer(fpv) + self.assertIsNotNone(serializer.data) + + +class FlowPageVisitGradeSerializerTest(SingleCourseTestMixin, TestCase): + def test_serializer(self): + fpvg = factories.FlowPageVisitGradeFactory() + serializer = FlowPageVisitGradeSerializer(fpvg) + self.assertIsNotNone(serializer.data) + # vim: foldmethod=marker