Commit ceed4afb authored by Marten Kenbeek's avatar Marten Kenbeek
Browse files

Added comments.

parent 5f7f1e9d
......@@ -9,6 +9,14 @@ from auth.users import User
class OAuth2Backend:
"""
Django Authentication backend that uses an external OAuth2 Authorization
Server to validate the access token.
Does not depend on django.contrib.auth, and doesn't save a local copy of
the user.
"""
_local = local()
access_token = {
"access_token": settings.AUTHORIZATION_SERVER_ACCESS_TOKEN,
......@@ -18,6 +26,13 @@ class OAuth2Backend:
}
def get_introspection_client(self):
"""
Get an OAuth2 client configured with the resource server's access
token. This client is reused for all requests in this thread.
It's not apparent whetehr OAuth2Session is thread-safe and can thus be
reused across every thread.
"""
try:
return self._local.client
except AttributeError:
......@@ -25,6 +40,11 @@ class OAuth2Backend:
return self._local.client
def authenticate(self, request):
"""
Retrieve the access token from the Authorization header, and validate
it against the Authorization Server. If the token is active, return a
User object with the retreived data.
"""
client = self.get_introspection_client()
if request.META.get("HTTP_AUTHORIZATION", "").startswith("Bearer"):
token = request.META["HTTP_AUTHORIZATION"][len("Bearer") :].strip()
......
......@@ -24,15 +24,24 @@ class OAuth2TokenMiddleware(MiddlewareMixin):
"""
def process_request(self, request):
"""
If the Authorization header is present and contains a Bearer token,
authenticate the user and set the user on the request.
"""
# do something only if request contains a Bearer token
if request.META.get("HTTP_AUTHORIZATION", "").startswith("Bearer"):
if not hasattr(request, "user") or request.user.is_anonymous:
user = authenticate(request=request)
if user:
# request._user and request._cached_user are used by various internals
# of Django and Django REST Framework.
request.user = request._user = request._cached_user = user
else: # pragma: no cover
return None
def process_response(self, request, response):
"""
Make sure authorized respones are not cached for different users.
"""
patch_vary_headers(response, ("Authorization",))
return response
class User:
"""
User object that replaces django.contrib.auth.models.User without
requiring database access.
"""
is_staff = False
is_superuser = False
......
......@@ -4,6 +4,11 @@ from coursera.models import ClickstreamEvent
class ClickstreamEventFilterSet(django_filters.FilterSet):
"""
FilterSet to filter clickstream events with a server_timestamp within
the given timespan.
"""
from_date = django_filters.DateTimeFilter(
field_name="server_timestamp", lookup_expr="gte"
)
......@@ -17,5 +22,10 @@ class ClickstreamEventFilterSet(django_filters.FilterSet):
class GenericFilterSet(django_filters.FilterSet):
"""
Generic FilterSet to filter any model with a "timestamp" field within the
given timespan.
"""
from_date = django_filters.DateTimeFilter(field_name="timestamp", lookup_expr="gte")
to_date = django_filters.DateTimeFilter(field_name="timestamp", lookup_expr="lte")
......@@ -71,7 +71,16 @@ class CourseSerializer(serializers.ModelSerializer):
@cached_property
def filter(self):
"""
Return a partial that applies the GenericFilterSet to the passed queryset.
Requires the request object to be in the context.
"""
def get_filterset(data=None, queryset=None, *, request=None, prefix=None):
"""
Apply the GenericFilterSet to the queryset, and return the filtered queryset.
"""
return GenericFilterSet(data, queryset, request=request, prefix=prefix).qs
return partial(
......@@ -241,7 +250,7 @@ class CourseAnalyticsSerializer(CourseSerializer):
def get_finished_learners_over_time(self, obj):
"""
For each month, show the cumulative number of students that has passed
For each date, show the cumulative number of students that has passed
the course `obj`.
"""
try:
......@@ -249,7 +258,7 @@ class CourseAnalyticsSerializer(CourseSerializer):
except AttributeError:
return list(
Grade.objects.filter(course_id=obj.pk)
.annotate(month=TruncDate("timestamp", output_field=DateField()))
.annotate(date=TruncDate("timestamp", output_field=DateField()))
.annotate(
num_finished=Window(
Count(
......@@ -262,7 +271,7 @@ class CourseAnalyticsSerializer(CourseSerializer):
)
)
.order_by(TruncDate("timestamp").asc())
.values_list("month", "num_finished")
.values_list("date", "num_finished")
.distinct()
)
......@@ -308,6 +317,10 @@ class CourseAnalyticsSerializer(CourseSerializer):
)
def get_average_time(self, obj):
"""
Return the average duration between each learners first and last activity
in the course.
"""
return obj.average_time
def get_average_time_per_module(self, obj):
......@@ -363,6 +376,9 @@ class CourseAnalyticsSerializer(CourseSerializer):
)
def get_cohort_list(self, obj):
"""
Return the list of cohorts in this course.
"""
return list(
obj.sessions.values_list("timestamp", "end_timestamp").order_by("timestamp")
)
......@@ -47,14 +47,22 @@ class VideoAnalyticsSerializer(ItemSerializer):
Calculates the following analytics:
watched_video: Number of people who started watching the video.
finished_video: Number of people who finished watching the video.
video_comments: Number of comments on the video.
video_likes: Number of likes on the video.
video_dislikes: Number of dislikes on the video.
next_item: Next item in the lesson.
next_video: Next item of type Video in the lesson.
views_over_runtime: Number of views per 5-second interval in the video.
watched_video:
Number of people who started watching the video.
finished_video:
Number of people who finished watching the video.
video_comments:
Number of comments on the video.
video_likes:
Number of likes on the video.
video_dislikes:
Number of dislikes on the video.
next_item:
Next item in the lesson.
next_video:
Next item of type Video in the lesson.
views_over_runtime
Number of views per 5-second interval in the video.
"""
class Meta(ItemSerializer.Meta):
......@@ -80,7 +88,18 @@ class VideoAnalyticsSerializer(ItemSerializer):
@cached_property
def filter(self):
"""
Return a partial that applies the GenericFilterSet to the passed
queryset.
Requires the request object to be in the context.
"""
def get_filterset(data=None, queryset=None, *, request=None, prefix=None):
"""
Apply the GenericFilterSet to the queryset, and return the
filtered queryset.
"""
return GenericFilterSet(data, queryset, request=request, prefix=prefix).qs
return partial(
......@@ -89,7 +108,18 @@ class VideoAnalyticsSerializer(ItemSerializer):
@cached_property
def clickstream_filter(self):
"""
Return a partial that applies the GenericFilterSet to the passed
queryset.
Requires the request object to be in the context.
"""
def get_filterset(data=None, queryset=None, *, request=None, prefix=None):
"""
Apply the ClickstreamEventFilterSet to the queryset, and return
the filtered queryset.
"""
return ClickstreamEventFilterSet(
data, queryset, request=request, prefix=prefix
).qs
......@@ -99,6 +129,10 @@ class VideoAnalyticsSerializer(ItemSerializer):
)
def get_watched_video(self, obj):
"""
Return the number of unique learners that have started watching the
video within the given timespan.
"""
try:
return obj.watched_video
except AttributeError:
......@@ -115,6 +149,10 @@ class VideoAnalyticsSerializer(ItemSerializer):
]
def get_finished_video(self, obj):
"""
Return the number of unique learners that have completed watching the
video within the given timespan.
"""
try:
return obj.finished_video
except AttributeError:
......@@ -131,6 +169,9 @@ class VideoAnalyticsSerializer(ItemSerializer):
]
def get_video_comments(self, obj):
"""
Return the number of comments on this video within the given timespan.
"""
try:
return obj.video_comments
except AttributeError:
......@@ -139,6 +180,9 @@ class VideoAnalyticsSerializer(ItemSerializer):
)["video_comments"]
def get_video_likes(self, obj):
"""
Return the number of likes on this video within the given timespan.
"""
try:
return obj.video_likes
except AttributeError:
......@@ -149,6 +193,9 @@ class VideoAnalyticsSerializer(ItemSerializer):
]
def get_video_dislikes(self, obj):
"""
Return the number of dislikes on this video within the given timespan.
"""
try:
return obj.video_dislikes
except AttributeError:
......@@ -159,6 +206,12 @@ class VideoAnalyticsSerializer(ItemSerializer):
]
def get_next_item(self, obj):
"""
Return the next item in the lesson, if any.
If the next item is a quiz, also include the passing rate of learners
who have watched the video.
"""
try:
return obj.next_item_id
except AttributeError:
......@@ -204,6 +257,9 @@ class VideoAnalyticsSerializer(ItemSerializer):
return {"item_id": "", "type": 0, "category": ""}
def get_next_video(self, obj):
"""
Return the next video in the lesson, if any.
"""
try:
return obj.next_video_id
except AttributeError:
......@@ -223,6 +279,10 @@ class VideoAnalyticsSerializer(ItemSerializer):
return {"item_id": "", "type": 0, "category": ""}
def get_views_over_runtime(self, obj):
"""
Return the number of views for each 5-second interval in the video,
within the given timespan.
"""
try:
return obj.views_over_runtime
except AttributeError:
......@@ -242,11 +302,16 @@ class AssignmentAnalyticsSerializer(ItemSerializer):
Calculates the following analytics:
submissions: Number of submissions to the assignment.
submission_ratio: Number of submissions divided by the number of enrolled students.
average_grade: The average grade of all students who completed the assignment.
next_item: The next item in the lesson.
next_assignment: The next item of type Assignment in the lesson.
submissions:
Number of submissions to the assignment.
submission_ratio:
Number of submissions divided by the number of enrolled students.
average_grade:
The average grade of all students who completed the assignment.
next_item:
The next item in the lesson.
next_assignment:
The next item of type Assignment in the lesson.
"""
class Meta(ItemSerializer.Meta):
......@@ -265,6 +330,9 @@ class AssignmentAnalyticsSerializer(ItemSerializer):
next_assignment = serializers.SerializerMethodField()
def get_next_item(self, obj):
"""
Return the next item in the lesson, if any.
"""
try:
return obj.next_item_id
except AttributeError:
......@@ -281,6 +349,9 @@ class AssignmentAnalyticsSerializer(ItemSerializer):
return {"item_id": "", "type": 0, "category": ""}
def get_next_assignment(self, obj):
"""
Return the next assignment in the lesson, if any.
"""
try:
return obj.next_video_id
except AttributeError:
......
......@@ -48,18 +48,31 @@ class QuizAnalyticsSerializer(QuizSerializer):
Calculates the following statistics:
average_grade: The average grade of all students who completed the quiz.
grade_distribution: The distribution of grades of all students who completed the quiz.
average_attempts: The average number of attempts per student.
number_of_attempts: The distribution of number of attempts per student.
correct_ratio_per_question: The ratio between correct responses and submitted responses per question.
quiz_comments: The number of comments on the quiz.
quiz_likes: The number of likes on the quiz.
quiz_dislikes: The number of dislikes on the quiz.
last_attempt_average_grade: The average grade of students' last attempt on the quiz.
last_attempt_grade_distribution: The distribution of grades of students' last attempt on the quiz.
next_item: The next item in the lesson.
next_quiz: The next item of type Quiz in the lesson.
average_grade:
The average grade of all students who completed the quiz.
grade_distribution:
The distribution of grades of all students who completed the quiz.
average_attempts:
The average number of attempts per student.
number_of_attempts:
The distribution of number of attempts per student.
correct_ratio_per_question:
The ratio between correct responses and submitted responses per
question.
quiz_comments:
The number of comments on the quiz.
quiz_likes:
The number of likes on the quiz.
quiz_dislikes:
The number of dislikes on the quiz.
last_attempt_average_grade:
The average grade of students' last attempt on the quiz.
last_attempt_grade_distribution:
The distribution of grades of students' last attempt on the quiz.
next_item:
The next item in the lesson.
next_quiz:
The next item of type Quiz in the lesson.
"""
class Meta(QuizSerializer.Meta):
......@@ -93,7 +106,18 @@ class QuizAnalyticsSerializer(QuizSerializer):
@cached_property
def filter(self):
"""
Return a partial that applies the GenericFilterSet to the passed
queryset.
Requires the request object to be in the context.
"""
def get_filterset(data=None, queryset=None, *, request=None, prefix=None):
"""
Apply the GenericFilterSet to the queryset, and return the filtered
queryset.
"""
return GenericFilterSet(data, queryset, request=request, prefix=prefix).qs
return partial(
......@@ -101,6 +125,10 @@ class QuizAnalyticsSerializer(QuizSerializer):
)
def get_grade_distribution(self, obj):
"""
Return the distribution of grades for this quiz within the given
timespan.
"""
return list(
self.filter(
ItemGrade.objects.filter(
......@@ -116,6 +144,10 @@ class QuizAnalyticsSerializer(QuizSerializer):
)
def get_average_attempts(self, obj):
"""
Return the average number of attempts for this quiz within the given
timespan.
"""
return (
self.filter(Attempt.objects.filter(quiz=obj))
.values("eitdigital_user_id")
......@@ -126,8 +158,8 @@ class QuizAnalyticsSerializer(QuizSerializer):
def get_number_of_attempts(self, obj):
"""
Return the number of users that have used a specific number of attempts
on the quiz. Every day on which a user submitted at least one response
is counted as an attempt.
on the quiz within the given timespan. Every day on which a user
submitted at least one response is counted as an attempt.
"""
return list(
self.filter(Attempt.objects.filter(quiz=obj))
......@@ -148,6 +180,10 @@ class QuizAnalyticsSerializer(QuizSerializer):
)
def get_correct_ratio_per_question(self, obj):
"""
Return the ratio between the number of correct answers and the total
number of answers for each question within the given timespan.
"""
queryset = self.filter(
obj.answer_count.values_list("question_id").order_by("question_id")
)
......@@ -172,6 +208,9 @@ class QuizAnalyticsSerializer(QuizSerializer):
)
def get_quiz_comments(self, obj):
"""
Return the number of comments on this quiz within the given timespan.
"""
return self.filter(
DiscussionQuestion.objects.filter(
item__quizzes=obj, course_id=self.context["course_id"]
......@@ -179,6 +218,9 @@ class QuizAnalyticsSerializer(QuizSerializer):
).aggregate(quiz_comments=Coalesce(Count("pk"), 0))["quiz_comments"]
def get_quiz_likes(self, obj):
"""
Return the number of likes on this quiz within the given timespan.
"""
return self.filter(
ItemRating.objects.filter(
item__quizzes=obj,
......@@ -190,6 +232,9 @@ class QuizAnalyticsSerializer(QuizSerializer):
]
def get_quiz_dislikes(self, obj):
"""
Return the number of dislikes on this quiz within the given timespan.
"""
return self.filter(
ItemRating.objects.filter(
item__quizzes=obj,
......@@ -202,12 +247,13 @@ class QuizAnalyticsSerializer(QuizSerializer):
def get_last_attempt_average_grade(self, obj):
"""
Return the average grade of the last attempts for a quiz.
Return the average grade of the last attempts for a quiz within the
given timespan.
A last attempt is the last submission for each question at a specific order
in the quiz. If the question at a specific order is replaced, and the user
submits a response to the new question, the response for the new question
is counted.
A last attempt is the last submission for each question at a specific
order in the quiz. If the question at a specific order is replaced,
and the user submits a response to the new question, the response for
the new question is counted.
"""
return self.filter(
obj.last_attempts.annotate(
......@@ -216,6 +262,10 @@ class QuizAnalyticsSerializer(QuizSerializer):
).aggregate(average_grade=Coalesce(Avg("grade"), 0))["average_grade"]
def get_last_attempt_grade_distribution(self, obj):
"""
Return the grade distribution of the last attempts for a quiz within
the given timespan.
"""
return list(
self.filter(
obj.last_attempts.annotate(
......@@ -225,6 +275,9 @@ class QuizAnalyticsSerializer(QuizSerializer):
)
def get_next_item(self, obj):
"""
Return the next item in the lesson, if any.
"""
try:
return obj.next_item_id
except AttributeError:
......@@ -243,6 +296,9 @@ class QuizAnalyticsSerializer(QuizSerializer):
return {"item_id": "", "type": 0, "category": ""}
def get_next_quiz(self, obj):
"""
Return the next quiz in the lesson, if any.
"""
try:
return obj.next_video_id
except AttributeError:
......
......@@ -8,12 +8,16 @@ from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet
from coursera.filters import GenericFilterSet
from coursera.models import (Branch, ClickstreamEvent, Course, Item, ItemType,
Quiz)
from coursera.serializers import (AssignmentAnalyticsSerializer,
CourseAnalyticsSerializer, CourseSerializer,
ItemSerializer, QuizAnalyticsSerializer,
QuizSerializer, VideoAnalyticsSerializer)
from coursera.models import Branch, ClickstreamEvent, Course, Item, ItemType, Quiz
from coursera.serializers import (
AssignmentAnalyticsSerializer,
CourseAnalyticsSerializer,
CourseSerializer,
ItemSerializer,
QuizAnalyticsSerializer,
QuizSerializer,
VideoAnalyticsSerializer,
)
class CourseAnalyticsViewSet(ReadOnlyModelViewSet):
......@@ -23,17 +27,39 @@ class CourseAnalyticsViewSet(ReadOnlyModelViewSet):
@cached_property
def generic_filterset(self):
"""
Return a partial that applies GenericFilterSet to a queryset and
returns the filtered queryset.
"""
def get_filterset(data=None, queryset=None, *, request=None, prefix=None):
"""
Apply GenericFilterSet and return the filtered queryset.
"""
return GenericFilterSet(data, queryset, request=request, prefix=prefix).qs
return partial(get_filterset, self.request.GET, request=self.request)
def get_serializer_class(self):
"""
Return CourseAnalyticsSerializer for single objects, and
CourseSerializer for multiple objects.
"""
if self.action == "retrieve":
return CourseAnalyticsSerializer
return super().get_serializer_class()
def get_queryset(self):
"""
Return the queryset of courses that the current user has access to.
Annotate with the number of enrolled, finished and paying learners and
the specialization name. For a single object, additionally annotate
with the number of modules, quizzes, assignments, videos and cohorts,
and with the average time spent on the course.
Order by specialization, then by name.
"""
queryset = (
super()
.get_queryset()
......@@ -73,11 +99,20 @@ class VideoAnalyticsViewSet(ReadOnlyModelViewSet):
lookup_url_kwarg = "item_id"
def get_serializer_class(self):
"""
Return VideoAnalyticsSerializer for single objects, VideoSerializer
for multiple objects.
"""
if self.action == "retrieve":
return VideoAnalyticsSerializer
return super().get_serializer_class()
def get_queryset(self):
"""
Return a queryset of videos that the current user has access to.
Order by the item order in the module.
"""
queryset = (
super()
.get_queryset()
......@@ -106,22 +141,50 @@ class QuizAnalyticsViewSet(ReadOnlyModelViewSet):
@cached_property
def generic_filterset(self):
"""
Return a partial that applies GenericFilterSet to a queryset and
returns the filtered queryset.
"""
def get_filterset(data=None, queryset=None, *, request=None, prefix=None):
"""
Apply GenericFilterSet and return the filtered queryset.
"""
return GenericFilterSet(data, queryset, request=request, prefix=prefix).qs
return partial(get_filterset, self.request.GET, request=self.request)
def get_serializer_class(self):
"""
Return QuizAnalyticsSerializer for single objects, QuizSerializer for
multiple objects.
"""