Commit 969b200f authored by Honcoop, T.'s avatar Honcoop, T.
Browse files

Merge branch 'feature/changed-list-analytics' into 'master'

Feature/changed list analytics

See merge request !3
parents a7d15311 c0ca3fbb
Pipeline #2178 passed with stage
in 2 minutes and 46 seconds
...@@ -17,7 +17,9 @@ from coursera.models import ( ...@@ -17,7 +17,9 @@ from coursera.models import (
__all__ = [ __all__ = [
"ItemSerializer", "ItemSerializer",
"VideoAnalyticsSerializer", "VideoAnalyticsSerializer",
"VideoSerializer",
"AssignmentAnalyticsSerializer", "AssignmentAnalyticsSerializer",
"AssignmentSerializer",
] ]
...@@ -45,14 +47,9 @@ class ItemSerializer(serializers.ModelSerializer): ...@@ -45,14 +47,9 @@ class ItemSerializer(serializers.ModelSerializer):
def get_lesson_name(self, obj): def get_lesson_name(self, obj):
return obj.lesson.name return obj.lesson.name
class VideoSerializer(ItemSerializer):
class VideoAnalyticsSerializer(ItemSerializer):
""" """
Serializer an Item of type Video with its basic properties
and calculated analytics.
Calculates the following analytics:
watched_video: watched_video:
Number of people who started watching the video. Number of people who started watching the video.
finished_video: finished_video:
...@@ -63,12 +60,6 @@ class VideoAnalyticsSerializer(ItemSerializer): ...@@ -63,12 +60,6 @@ class VideoAnalyticsSerializer(ItemSerializer):
Number of likes on the video. Number of likes on the video.
video_dislikes: video_dislikes:
Number of dislikes on the video. 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): class Meta(ItemSerializer.Meta):
...@@ -78,19 +69,13 @@ class VideoAnalyticsSerializer(ItemSerializer): ...@@ -78,19 +69,13 @@ class VideoAnalyticsSerializer(ItemSerializer):
"video_comments", "video_comments",
"video_likes", "video_likes",
"video_dislikes", "video_dislikes",
"next_item",
"next_video",
"views_over_runtime",
] ]
watched_video = serializers.SerializerMethodField() watched_video = serializers.SerializerMethodField()
finished_video = serializers.SerializerMethodField() finished_video = serializers.SerializerMethodField()
video_comments = serializers.SerializerMethodField() video_comments = serializers.SerializerMethodField()
video_likes = serializers.SerializerMethodField() video_likes = serializers.SerializerMethodField()
video_dislikes = serializers.SerializerMethodField() video_dislikes = serializers.SerializerMethodField()
next_item = serializers.SerializerMethodField()
next_video = serializers.SerializerMethodField()
views_over_runtime = serializers.SerializerMethodField()
@cached_property @cached_property
def filter(self): def filter(self):
...@@ -211,6 +196,74 @@ class VideoAnalyticsSerializer(ItemSerializer): ...@@ -211,6 +196,74 @@ class VideoAnalyticsSerializer(ItemSerializer):
"video_likes" "video_likes"
] ]
class VideoAnalyticsSerializer(VideoSerializer):
"""
Serializer an Item of type Video with its basic properties
and calculated analytics.
Calculates the following analytics:
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(VideoSerializer.Meta):
fields = VideoSerializer.Meta.fields + [
"next_item",
"next_video",
"views_over_runtime",
]
next_item = serializers.SerializerMethodField()
next_video = serializers.SerializerMethodField()
views_over_runtime = serializers.SerializerMethodField()
@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(
get_filterset, self.context["request"].GET, request=self.context["request"]
)
@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
return partial(
get_filterset, self.context["request"].GET, request=self.context["request"]
)
def get_next_item(self, obj): def get_next_item(self, obj):
""" """
Return the next item in the lesson, if any. Return the next item in the lesson, if any.
...@@ -300,8 +353,7 @@ class VideoAnalyticsSerializer(ItemSerializer): ...@@ -300,8 +353,7 @@ class VideoAnalyticsSerializer(ItemSerializer):
) )
) )
class AssignmentSerializer(ItemSerializer):
class AssignmentAnalyticsSerializer(ItemSerializer):
""" """
Serialize an Item of type Assignment with its basic properties Serialize an Item of type Assignment with its basic properties
and calculated analytics. and calculated analytics.
...@@ -314,24 +366,40 @@ class AssignmentAnalyticsSerializer(ItemSerializer): ...@@ -314,24 +366,40 @@ class AssignmentAnalyticsSerializer(ItemSerializer):
Number of submissions divided by the number of enrolled students. Number of submissions divided by the number of enrolled students.
average_grade: average_grade:
The average grade of all students who completed the assignment. 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): class Meta(ItemSerializer.Meta):
fields = ItemSerializer.Meta.fields + [ fields = ItemSerializer.Meta.fields + [
"submissions", "submissions",
"submission_ratio", "submission_ratio",
"average_grade", "average_grade",
"next_item",
"next_assignment",
] ]
submissions = serializers.IntegerField() submissions = serializers.IntegerField()
submission_ratio = serializers.FloatField() submission_ratio = serializers.FloatField()
average_grade = serializers.FloatField() average_grade = serializers.FloatField()
class AssignmentAnalyticsSerializer(AssignmentSerializer):
"""
Serialize an Item of type Assignment with its basic properties
and calculated analytics.
Calculates the following analytics:
next_item:
The next item in the lesson.
next_assignment:
The next item of type Assignment in the lesson.
"""
class Meta(AssignmentSerializer.Meta):
fields = AssignmentSerializer.Meta.fields + [
"next_item",
"next_assignment",
]
next_item = serializers.SerializerMethodField() next_item = serializers.SerializerMethodField()
next_assignment = serializers.SerializerMethodField() next_assignment = serializers.SerializerMethodField()
......
...@@ -21,7 +21,15 @@ __all__ = ["QuizSerializer", "QuizAnalyticsSerializer"] ...@@ -21,7 +21,15 @@ __all__ = ["QuizSerializer", "QuizAnalyticsSerializer"]
class QuizSerializer(serializers.ModelSerializer): class QuizSerializer(serializers.ModelSerializer):
""" """
Serialize a Quiz with its basic properties. Serialize a Quiz with its basic properties and
some basic statistics.
Calculates the following statistics:
average_grade:
The average grade of all students who completed the quiz.
average_attempts:
The average number of attempts per student.
""" """
class Meta: class Meta:
...@@ -38,6 +46,8 @@ class QuizSerializer(serializers.ModelSerializer): ...@@ -38,6 +46,8 @@ class QuizSerializer(serializers.ModelSerializer):
"lesson", "lesson",
"lesson_name", "lesson_name",
"item_id", "item_id",
"average_grade",
"average_attempts",
] ]
name = serializers.CharField() name = serializers.CharField()
...@@ -45,6 +55,28 @@ class QuizSerializer(serializers.ModelSerializer): ...@@ -45,6 +55,28 @@ class QuizSerializer(serializers.ModelSerializer):
lesson = serializers.SerializerMethodField() lesson = serializers.SerializerMethodField()
lesson_name = serializers.SerializerMethodField() lesson_name = serializers.SerializerMethodField()
item_id = serializers.SerializerMethodField() item_id = serializers.SerializerMethodField()
average_grade = serializers.FloatField()
average_attempts = serializers.SerializerMethodField()
@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(
get_filterset, self.context["request"].GET, request=self.context["request"]
)
def get_lesson(self, obj): def get_lesson(self, obj):
return obj.items.all()[0].lesson.lesson_id return obj.items.all()[0].lesson.lesson_id
...@@ -55,6 +87,18 @@ class QuizSerializer(serializers.ModelSerializer): ...@@ -55,6 +87,18 @@ class QuizSerializer(serializers.ModelSerializer):
def get_item_id(self, obj): def get_item_id(self, obj):
return obj.items.all()[0].item_id return obj.items.all()[0].item_id
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")
.annotate(number_of_attempts=Count("timestamp"))
.aggregate(average=Coalesce(Avg("number_of_attempts"), 0))["average"]
)
class QuizAnalyticsSerializer(QuizSerializer): class QuizAnalyticsSerializer(QuizSerializer):
""" """
Serialize a Quiz with its basic properties Serialize a Quiz with its basic properties
...@@ -62,12 +106,8 @@ class QuizAnalyticsSerializer(QuizSerializer): ...@@ -62,12 +106,8 @@ class QuizAnalyticsSerializer(QuizSerializer):
Calculates the following statistics: Calculates the following statistics:
average_grade:
The average grade of all students who completed the quiz.
grade_distribution: grade_distribution:
The distribution of grades of all students who completed the quiz. The distribution of grades of all students who completed the quiz.
average_attempts:
The average number of attempts per student.
number_of_attempts: number_of_attempts:
The distribution of number of attempts per student. The distribution of number of attempts per student.
correct_ratio_per_question: correct_ratio_per_question:
...@@ -91,9 +131,7 @@ class QuizAnalyticsSerializer(QuizSerializer): ...@@ -91,9 +131,7 @@ class QuizAnalyticsSerializer(QuizSerializer):
class Meta(QuizSerializer.Meta): class Meta(QuizSerializer.Meta):
fields = QuizSerializer.Meta.fields + [ fields = QuizSerializer.Meta.fields + [
"average_grade",
"grade_distribution", "grade_distribution",
"average_attempts",
"number_of_attempts", "number_of_attempts",
"correct_ratio_per_question", "correct_ratio_per_question",
"quiz_comments", "quiz_comments",
...@@ -105,10 +143,8 @@ class QuizAnalyticsSerializer(QuizSerializer): ...@@ -105,10 +143,8 @@ class QuizAnalyticsSerializer(QuizSerializer):
"next_quiz", "next_quiz",
] ]
average_grade = serializers.FloatField()
grade_distribution = serializers.SerializerMethodField() grade_distribution = serializers.SerializerMethodField()
number_of_attempts = serializers.SerializerMethodField() number_of_attempts = serializers.SerializerMethodField()
average_attempts = serializers.SerializerMethodField()
correct_ratio_per_question = serializers.SerializerMethodField() correct_ratio_per_question = serializers.SerializerMethodField()
quiz_comments = serializers.SerializerMethodField() quiz_comments = serializers.SerializerMethodField()
quiz_likes = serializers.SerializerMethodField() quiz_likes = serializers.SerializerMethodField()
...@@ -157,18 +193,6 @@ class QuizAnalyticsSerializer(QuizSerializer): ...@@ -157,18 +193,6 @@ class QuizAnalyticsSerializer(QuizSerializer):
.annotate(num_grades=Count("eitdigital_user")) .annotate(num_grades=Count("eitdigital_user"))
) )
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")
.annotate(number_of_attempts=Count("timestamp"))
.aggregate(average=Coalesce(Avg("number_of_attempts"), 0))["average"]
)
def get_number_of_attempts(self, obj): def get_number_of_attempts(self, obj):
""" """
Return the number of users that have used a specific number of attempts Return the number of users that have used a specific number of attempts
......
...@@ -11,12 +11,14 @@ from coursera.filters import GenericFilterSet ...@@ -11,12 +11,14 @@ from coursera.filters import GenericFilterSet
from coursera.models import Branch, ClickstreamEvent, Course, Item, ItemType, Quiz from coursera.models import Branch, ClickstreamEvent, Course, Item, ItemType, Quiz
from coursera.serializers import ( from coursera.serializers import (
AssignmentAnalyticsSerializer, AssignmentAnalyticsSerializer,
AssignmentSerializer,
CourseAnalyticsSerializer, CourseAnalyticsSerializer,
CourseSerializer, CourseSerializer,
ItemSerializer, ItemSerializer,
QuizAnalyticsSerializer, QuizAnalyticsSerializer,
QuizSerializer, QuizSerializer,
VideoAnalyticsSerializer, VideoAnalyticsSerializer,
VideoSerializer,
) )
...@@ -92,7 +94,7 @@ class VideoAnalyticsViewSet(ReadOnlyModelViewSet): ...@@ -92,7 +94,7 @@ class VideoAnalyticsViewSet(ReadOnlyModelViewSet):
.values("pk")[:1] .values("pk")[:1]
) )
) )
serializer_class = ItemSerializer serializer_class = VideoSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
lookup_field = "item_id" lookup_field = "item_id"
...@@ -193,8 +195,7 @@ class QuizAnalyticsViewSet(ReadOnlyModelViewSet): ...@@ -193,8 +195,7 @@ class QuizAnalyticsViewSet(ReadOnlyModelViewSet):
.annotate(name=F("items__name")) .annotate(name=F("items__name"))
.annotate(graded=F("items__type__graded")) .annotate(graded=F("items__type__graded"))
) )
if self.action == "retrieve": queryset = queryset.with_average_grade(self.generic_filterset)
queryset = queryset.with_average_grade(self.generic_filterset)
if "base_id" in self.kwargs: if "base_id" in self.kwargs:
queryset = queryset.filter(base_id=self.kwargs["base_id"]).order_by( queryset = queryset.filter(base_id=self.kwargs["base_id"]).order_by(
...@@ -221,7 +222,7 @@ class AssignmentAnalyticsViewSet(ReadOnlyModelViewSet): ...@@ -221,7 +222,7 @@ class AssignmentAnalyticsViewSet(ReadOnlyModelViewSet):
.values("pk")[:1] .values("pk")[:1]
) )
) )
serializer_class = ItemSerializer serializer_class = AssignmentSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
lookup_field = "item_id" lookup_field = "item_id"
...@@ -266,12 +267,11 @@ class AssignmentAnalyticsViewSet(ReadOnlyModelViewSet): ...@@ -266,12 +267,11 @@ class AssignmentAnalyticsViewSet(ReadOnlyModelViewSet):
.filter(branch__course__in=self.request.user.courses) .filter(branch__course__in=self.request.user.courses)
.filter(branch__course=self.kwargs["course_id"]) .filter(branch__course=self.kwargs["course_id"])
) )
if self.action == "retrieve": queryset = (
queryset = ( queryset.with_submissions(self.generic_filterset)
queryset.with_submissions(self.generic_filterset) .with_submission_ratio(self.generic_filterset)
.with_submission_ratio(self.generic_filterset) .with_average_grade(self.generic_filterset)
.with_average_grade(self.generic_filterset) )
)
if self.action == "list": if self.action == "list":
queryset = queryset.order_by( queryset = queryset.order_by(
"lesson__module__order", "lesson__order", "order" "lesson__module__order", "lesson__order", "order"
......
...@@ -524,11 +524,31 @@ def test_video_list_view(teacher_api_client, coursera_course_id): ...@@ -524,11 +524,31 @@ def test_video_list_view(teacher_api_client, coursera_course_id):
- type - type
- name - name
- optional - optional
- watched_video
- finished_video
- video_comments
- video_likes
- video_dislikes
""" """
response = teacher_api_client.get( response = teacher_api_client.get(
reverse("coursera-api:video-list", kwargs={"course_id": coursera_course_id}) reverse("coursera-api:video-list", kwargs={"course_id": coursera_course_id})
) )
keys = ["id", "branch", "item_id", "lesson", "lesson_name", "order", "type", "name", "optional"] keys = [
"id",
"branch",
"item_id",
"lesson",
"lesson_name",
"order",
"type",
"name",
"optional",
"watched_video",
"finished_video",
"video_comments",
"video_likes",
"video_dislikes",
]
assert response.status_code == 200, str(response.content) assert response.status_code == 200, str(response.content)
assert len(response.data) > 0, "no videos returned" assert len(response.data) > 0, "no videos returned"
for item in response.data: for item in response.data:
...@@ -559,8 +579,8 @@ def test_quiz_analytics_view( ...@@ -559,8 +579,8 @@ def test_quiz_analytics_view(
- lesson_name - lesson_name
- item_id - item_id
- average_grade - average_grade
- grade_distribution
- average_attempts - average_attempts
- grade_distribution
- number_of_attempts - number_of_attempts
- correct_ratio_per_question - correct_ratio_per_question
- quiz_comments - quiz_comments
...@@ -594,8 +614,8 @@ def test_quiz_analytics_view( ...@@ -594,8 +614,8 @@ def test_quiz_analytics_view(
"lesson_name", "lesson_name",
"item_id", "item_id",
"average_grade", "average_grade",
"grade_distribution",
"average_attempts", "average_attempts",
"grade_distribution",
"number_of_attempts", "number_of_attempts",
"correct_ratio_per_question", "correct_ratio_per_question",
"quiz_comments", "quiz_comments",
...@@ -737,8 +757,8 @@ def test_quiz_analytics_view_invalid_date_filter( ...@@ -737,8 +757,8 @@ def test_quiz_analytics_view_invalid_date_filter(
"lesson_name", "lesson_name",
"item_id", "item_id",
"average_grade", "average_grade",
"grade_distribution",
"average_attempts", "average_attempts",
"grade_distribution",
"number_of_attempts", "number_of_attempts",
"correct_ratio_per_question", "correct_ratio_per_question",
"quiz_comments", "quiz_comments",
...@@ -786,8 +806,8 @@ def test_quiz_analytics_next_quiz( ...@@ -786,8 +806,8 @@ def test_quiz_analytics_next_quiz(
"lesson_name", "lesson_name",
"item_id", "item_id",
"average_grade", "average_grade",
"grade_distribution",
"average_attempts", "average_attempts",
"grade_distribution",
"number_of_attempts", "number_of_attempts",
"correct_ratio_per_question", "correct_ratio_per_question",
"quiz_comments", "quiz_comments",
...@@ -845,6 +865,8 @@ def test_quiz_version_list_view( ...@@ -845,6 +865,8 @@ def test_quiz_version_list_view(
- lesson - lesson
- lesson_name - lesson_name
- item_id - item_id
- average_grade
- average_attempts
""" """
response = teacher_api_client.get( response = teacher_api_client.get(
reverse( reverse(
...@@ -867,6 +889,8 @@ def test_quiz_version_list_view( ...@@ -867,6 +889,8 @@ def test_quiz_version_list_view(
"lesson", "lesson",
"lesson_name", "lesson_name",
"item_id", "item_id",
"average_grade",
"average_attempts",
] ]
assert response.status_code == 200, str(response.content) assert response.status_code == 200, str(response.content)
assert len(response.data) > 0, "no quizzes returned" assert len(response.data) > 0, "no quizzes returned"
...@@ -893,6 +917,8 @@ def test_quiz_list_view(teacher_api_client, coursera_course_id): ...@@ -893,6 +917,8 @@ def test_quiz_list_view(teacher_api_client, coursera_course_id):
- lesson - lesson
- lesson_name - lesson_name
- item_id - item_id
- average_grade
- average_attempts
"""