Skip to content

Commit

Permalink
API endpoint for getting submission statistics
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
PasiSa authored and markkuriekkinen committed Mar 14, 2022
1 parent cfd9ac9 commit fd0924e
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 1 deletion.
5 changes: 5 additions & 0 deletions api/urls_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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/<int:course_id>/statistics/', course.api.views.CourseStatisticsView.as_view(), name='course-statistics'),
path('exercises/<int:exercise_id>/statistics/', exercise.api.views.ExerciseStatisticsView.as_view(), name='exercise-statistics'),
]


Expand Down
13 changes: 12 additions & 1 deletion course/api/full_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +32,7 @@
'CourseUsertaggingsSerializer',
'TreeCourseModuleSerializer',
'CourseNewsSerializer',
'CourseStatisticsSerializer',
]


Expand Down Expand Up @@ -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 = (
Expand All @@ -109,6 +115,7 @@ class Meta(CourseBriefSerializer.Meta):
'groups',
'my_groups',
'news',
'statistics',
)


Expand Down Expand Up @@ -357,3 +364,7 @@ class Meta(CourseNewsBriefSerializer.Meta):
'view_name': 'api:course-news-detail',
}
}


class CourseStatisticsSerializer(StatisticsSerializer):
course_id = serializers.IntegerField(read_only=True)
38 changes: 38 additions & 0 deletions course/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<course_id>/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
8 changes: 8 additions & 0 deletions exercise/api/full_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
CompositeListSerializer,
AplusSerializerMeta,
AplusModelSerializerBase,
StatisticsSerializer,
)
from course.api.serializers import CourseBriefSerializer
from userprofile.api.serializers import UserBriefSerializer, UserListField
Expand All @@ -24,6 +25,7 @@
'SubmissionSerializer',
'SubmissionGraderSerializer',
'TreeExerciseSerializer',
'ExerciseStatisticsSerializer',
]


Expand All @@ -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
Expand All @@ -69,6 +72,7 @@ class Meta(ExerciseBriefSerializer.Meta):
'submissions',
'my_submissions',
'my_stats',
'statistics',
)


Expand Down Expand Up @@ -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)
39 changes: 39 additions & 0 deletions exercise/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<exercise_id>/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
7 changes: 7 additions & 0 deletions lib/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
55 changes: 55 additions & 0 deletions lib/api/statistics.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit fd0924e

Please sign in to comment.