From fd0924e6f31706ce6db038edd338fab7ebbe50e1 Mon Sep 17 00:00:00 2001 From: Pasi Sarolahti Date: Tue, 22 Feb 2022 20:23:37 +0200 Subject: [PATCH] API endpoint for getting submission statistics Returns number of submissions, and number of distinct users submitting over time window given as query parameters. These statistics can be queried for a specific course, for a specific exercise, or through all courses in system. Closes #989. --- api/urls_v2.py | 5 +++ course/api/full_serializers.py | 13 +++++++- course/api/views.py | 38 ++++++++++++++++++++++ exercise/api/full_serializers.py | 8 +++++ exercise/api/views.py | 39 ++++++++++++++++++++++ lib/api/serializers.py | 7 ++++ lib/api/statistics.py | 55 ++++++++++++++++++++++++++++++++ 7 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 lib/api/statistics.py diff --git a/api/urls_v2.py b/api/urls_v2.py index 51797a85d..880e93bdf 100644 --- a/api/urls_v2.py +++ b/api/urls_v2.py @@ -2,6 +2,7 @@ from django.conf import settings from django.conf.urls import url, include +from django.urls import path from rest_framework_extensions.routers import ExtendedDefaultRouter import userprofile.api.views @@ -10,6 +11,7 @@ import exercise.api.csv.views import external_services.api.views import authorization.api.views +import lib.api.statistics class AplusRouter(ExtendedDefaultRouter): @@ -90,6 +92,9 @@ class AplusRouter(ExtendedDefaultRouter): url(r"^get-token", authorization.api.views.RemoteAuthenticationView.as_view(), name="get-token"), url(r'^me', userprofile.api.views.MeDetail.as_view()), url(r'^lti-outcomes', external_services.api.views.LTIExerciseBasicOutcomesView.as_view(), name='lti-outcomes'), + path('statistics/', lib.api.statistics.BaseStatisticsView.as_view(), name='statistics'), + path('courses//statistics/', course.api.views.CourseStatisticsView.as_view(), name='course-statistics'), + path('exercises//statistics/', exercise.api.views.ExerciseStatisticsView.as_view(), name='exercise-statistics'), ] diff --git a/course/api/full_serializers.py b/course/api/full_serializers.py index 11e2b0c0d..1b669cb2e 100644 --- a/course/api/full_serializers.py +++ b/course/api/full_serializers.py @@ -2,7 +2,11 @@ from rest_framework import serializers, exceptions from lib.api.fields import NestedHyperlinkedIdentityField -from lib.api.serializers import AplusModelSerializer, NestedHyperlinkedIdentityFieldWithQuery +from lib.api.serializers import ( + AplusModelSerializer, + NestedHyperlinkedIdentityFieldWithQuery, + StatisticsSerializer, +) from exercise.api.full_serializers import TreeExerciseSerializer from exercise.api.serializers import ExerciseBriefSerializer from userprofile.api.serializers import UserBriefSerializer, UserListField @@ -28,6 +32,7 @@ 'CourseUsertaggingsSerializer', 'TreeCourseModuleSerializer', 'CourseNewsSerializer', + 'CourseStatisticsSerializer', ] @@ -87,6 +92,7 @@ class CourseSerializer(CourseBriefSerializer): groups = NestedHyperlinkedIdentityField(view_name='api:course-groups-list') my_groups = NestedHyperlinkedIdentityField(view_name='api:course-mygroups-list') news = NestedHyperlinkedIdentityField(view_name='api:course-news-list') + statistics = NestedHyperlinkedIdentityField(view_name='course-statistics') class Meta(CourseBriefSerializer.Meta): fields = ( @@ -109,6 +115,7 @@ class Meta(CourseBriefSerializer.Meta): 'groups', 'my_groups', 'news', + 'statistics', ) @@ -357,3 +364,7 @@ class Meta(CourseNewsBriefSerializer.Meta): 'view_name': 'api:course-news-detail', } } + + +class CourseStatisticsSerializer(StatisticsSerializer): + course_id = serializers.IntegerField(read_only=True) diff --git a/course/api/views.py b/course/api/views.py index 18bea82bf..ee140b4a8 100644 --- a/course/api/views.py +++ b/course/api/views.py @@ -16,6 +16,7 @@ from lib.api.filters import FieldValuesFilter from lib.api.mixins import ListSerializerMixin, MeUserMixin from lib.api.constants import REGEX_INT, REGEX_INT_ME +from lib.api.statistics import BaseStatisticsView from userprofile.permissions import IsAdminOrUserObjIsSelf from news.models import News @@ -577,3 +578,40 @@ def get_queryset(self) -> QuerySet[News]: Q(audience=AUDIENCE.INTERNAL_USERS) ) return queryset + + +class CourseStatisticsView(BaseStatisticsView): + """ + Returns submission statistics for a course, over a given time window. + + Returns the following attributes: + + - `submission_count`: total number of submissions. + - `submitters`: number of users submitting. + + Operations + ---------- + + `GET /courses//statistics/`: + returns the statistics for the given course. + + - URL parameters: + - `endtime`: date and time in ISO 8601 format indicating the end point + of time window we are interested in. Default: now. + - `starttime`: date and time in ISO 8601 format indicating the start point + of time window we are interested in. Default: one day before endtime + """ + + serializer_class = CourseStatisticsSerializer + + def get_queryset(self) -> QuerySet: + queryset = super().get_queryset() + course_id = self.kwargs['course_id'] + return queryset.filter( + exercise__course_module__course_instance=course_id, + ) + + def get_object(self): + obj = super().get_object() + obj.update({ 'course_id': self.kwargs['course_id'] }) + return obj diff --git a/exercise/api/full_serializers.py b/exercise/api/full_serializers.py index 276e2ed5d..172897b98 100644 --- a/exercise/api/full_serializers.py +++ b/exercise/api/full_serializers.py @@ -6,6 +6,7 @@ CompositeListSerializer, AplusSerializerMeta, AplusModelSerializerBase, + StatisticsSerializer, ) from course.api.serializers import CourseBriefSerializer from userprofile.api.serializers import UserBriefSerializer, UserListField @@ -24,6 +25,7 @@ 'SubmissionSerializer', 'SubmissionGraderSerializer', 'TreeExerciseSerializer', + 'ExerciseStatisticsSerializer', ] @@ -49,6 +51,7 @@ class ExerciseSerializer(ExerciseBriefSerializer): 'user_id': lambda o=None: 'me', }, ) + statistics = NestedHyperlinkedIdentityField(view_name='exercise-statistics') def get_post_url(self, obj): # FIXME: obj should implement .get_post_url() and that should be used here @@ -69,6 +72,7 @@ class Meta(ExerciseBriefSerializer.Meta): 'submissions', 'my_submissions', 'my_stats', + 'statistics', ) @@ -187,3 +191,7 @@ def get_children(self, obj): context=self.context ) return serializer.data + + +class ExerciseStatisticsSerializer(StatisticsSerializer): + exercise_id = serializers.IntegerField(read_only=True) diff --git a/exercise/api/views.py b/exercise/api/views.py index eab0f6f8d..f43373b45 100644 --- a/exercise/api/views.py +++ b/exercise/api/views.py @@ -12,10 +12,12 @@ from rest_framework.settings import api_settings from rest_framework_extensions.mixins import NestedViewSetMixin from django.db import DatabaseError +from django.db.models import QuerySet from authorization.permissions import ACCESS from lib.api.mixins import MeUserMixin, ListSerializerMixin from lib.api.constants import REGEX_INT, REGEX_INT_ME +from lib.api.statistics import BaseStatisticsView from userprofile.models import UserProfile, GraderUser from course.permissions import ( IsCourseAdminOrUserObjIsSelf, @@ -499,3 +501,40 @@ def get_queryset(self): if self.action == 'list': return self.instance.students return self.instance.course_staff_and_students + + +class ExerciseStatisticsView(BaseStatisticsView): + """ + Returns submission statistics for an exercise, over a given time window. + + Returns the following attributes: + + - `submission_count`: total number of submissions. + - `submitters`: number of users submitting. + + Operations + ---------- + + `GET /exercises//statistics/`: + returns the statistics for the given exercise. + + - URL parameters: + - `endtime`: date and time in ISO 8601 format indicating the end point + of time window we are interested in. Default: now. + - `starttime`: date and time in ISO 8601 format indicating the start point + of time window we are interested in. Default: one day before endtime + """ + + serializer_class = ExerciseStatisticsSerializer + + def get_queryset(self) -> QuerySet: + queryset = super().get_queryset() + exercise_id = self.kwargs['exercise_id'] + return queryset.filter( + exercise=exercise_id, + ) + + def get_object(self): + obj = super().get_object() + obj.update({ 'exercise_id': self.kwargs['exercise_id'] }) + return obj diff --git a/lib/api/serializers.py b/lib/api/serializers.py index 99f394b80..3e892d51c 100644 --- a/lib/api/serializers.py +++ b/lib/api/serializers.py @@ -173,3 +173,10 @@ class Meta(AplusSerializerMeta): 'id', 'url', ) + + +class StatisticsSerializer(serializers.Serializer): + starttime = serializers.DateTimeField(allow_null=True) + endtime = serializers.DateTimeField(allow_null=True) + submission_count = serializers.IntegerField(read_only=True) + submitters = serializers.IntegerField(read_only=True) diff --git a/lib/api/statistics.py b/lib/api/statistics.py new file mode 100644 index 000000000..f2bcc91e3 --- /dev/null +++ b/lib/api/statistics.py @@ -0,0 +1,55 @@ +from datetime import datetime, timedelta + +from rest_framework import status +from rest_framework import generics +from django.db.models import Count, QuerySet +from django.utils import timezone + +from exercise.submission_models import Submission +from .serializers import StatisticsSerializer + + +class BaseStatisticsView(generics.RetrieveAPIView): + """ + Returns submission statistics for the entire system, over a given time window. + + Returns the following attributes: + + - `submission_count`: total number of submissions. + - `submitters`: number of users submitting. + + Operations + ---------- + + `GET /statistics/`: + returns the statistics for the system. + + - URL parameters: + - `endtime`: date and time in ISO 8601 format indicating the end point + of time window we are interested in. Default: now. + - `starttime`: date and time in ISO 8601 format indicating the start point + of time window we are interested in. Default: one day before endtime + """ + serializer_class = StatisticsSerializer + + def get_queryset(self) -> QuerySet: + queryset = Submission.objects.all() + + endtime = self.request.query_params.get('endtime') + starttime = self.request.query_params.get('starttime') + serializer = self.get_serializer(data={'starttime': starttime, 'endtime': endtime}) + serializer.is_valid(raise_exception=True) + self.endtime = serializer.validated_data['endtime'] or timezone.now() + self.starttime = serializer.validated_data['starttime'] or self.endtime - timedelta(days=1) + + return queryset.filter(submission_time__range=[self.starttime, self.endtime]) + + def get_object(self): + qs = self.get_queryset() + obj = { + 'starttime': self.starttime, + 'endtime': self.endtime, + 'submission_count': qs.count(), + 'submitters': qs.values('submitters').distinct().count() + } + return obj