Skip to content

Commit

Permalink
Added Enrollment Metrics endpoint to Figures
Browse files Browse the repository at this point in the history
This endpoint retrieves data from Figures LearnerCourseGradeMetrics
model
- Added new viewset for endpoint `/figures/api/enrollment-metrics/`
- Added serializers and filters to support the new endpoint
- Added view tests
  • Loading branch information
johnbaldwin committed Jul 6, 2020
1 parent e4fed41 commit a32602e
Show file tree
Hide file tree
Showing 6 changed files with 330 additions and 9 deletions.
90 changes: 86 additions & 4 deletions figures/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.db.models import F

import django_filters

from opaque_keys.edx.keys import CourseKey
Expand All @@ -26,23 +28,33 @@
CourseDailyMetrics,
SiteDailyMetrics,
CourseMauMetrics,
LearnerCourseGradeMetrics,
SiteMauMetrics,
)


def char_method_filter(method):
"""
method is the method name string
Check if old style first
Pre v1:
"method" is the method name string
First check for old style (pre version 1 Django Filters)
"""
if hasattr(django_filters, 'MethodFilter'):
return django_filters.MethodFilter(action=method) # pylint: disable=no-member
else:
return django_filters.CharFilter(method=method)


def boolean_method_filter(method):
"""
"method" is the method name string
First check for old style (pre version 1 Django Filters)
"""
if hasattr(django_filters, 'MethodFilter'):
return django_filters.MethodFilter(action=method)
else:
return django_filters.BooleanFilter(method=method)


class CourseOverviewFilter(django_filters.FilterSet):
'''Provides filtering for CourseOverview model objects
Expand Down Expand Up @@ -101,6 +113,76 @@ class Meta:
fields = ['course_id', 'user_id', 'is_active', ]


class EnrollmentMetricsFilter(CourseEnrollmentFilter):
"""Filter query params for enrollment metrics
Consider making 'user_ids' and 'course_ids' be mixins for `user` foreign key
and 'course_id' respectively. Perhaps a class decorator if there's some
unforseen issue with doing a mixin for each
Filters
"course_ids" filters on a set of comma delimited course id strings
"user_ids" filters on a set of comma delimited integer user ids
"only_completed" shows only completed records. Django Filter 1.0.4 appears
to only support capitalized "True" as the value in the query string
The "only_completed" filter is subject to change. We want to be able to
filter on: "hide completed", "show only completed", "show everything"
So we may go with a "choice field"
Use ``date_for`` for retrieving a specific date
Use ``date_0`` and ``date_1`` for retrieving values in a date range, inclusive
each of these can be used singly to get:
* ``date_0`` to get records greater than or equal
* ``date_1`` to get records less than or equal
TODO: Add 'is_active' filter - need to find matches in CourseEnrollment
"""
course_ids = char_method_filter(method='filter_course_ids')
user_ids = char_method_filter(method='filter_user_ids')
date = django_filters.DateFromToRangeFilter(name='date_for')
only_completed = boolean_method_filter(method='filter_only_completed')
exclude_completed = boolean_method_filter(method='filter_exclude_completed')

class Meta:
"""
Allow all field and related filtering except for "site"
"""
model = LearnerCourseGradeMetrics
exclude = ['site']

def filter_course_ids(self, queryset, name, value): # pylint: disable=unused-argument
course_ids = [cid.replace(' ', '+') for cid in value.split(',')]
return queryset.filter(course_id__in=course_ids)

def filter_user_ids(self, queryset, name, value): # pylint: disable=unused-argument
"""
"""
user_ids = [user_id for user_id in value.split(',') if user_id.isdigit()]
return queryset.filter(user_id__in=user_ids)

def filter_only_completed(self, queryset, name, value): # pylint: disable=unused-argument
"""
The "value" parameter is either `True` or `False`
"""
if value:
return queryset.filter(sections_possible__gt=0,
sections_worked=F('sections_possible'))
else:
return queryset

def filter_exclude_completed(self, queryset, name, value): # pylint: disable=unused-argument
"""
The "value" parameter is either `True` or `False`
"""
if value:
# This is a hack until we add `completed` field to LCGM
return queryset.filter(sections_worked__lt=F('sections_possible'))
else:
return queryset


class UserFilterSet(django_filters.FilterSet):
'''Provides filtering for User model objects
Expand Down
23 changes: 23 additions & 0 deletions figures/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,3 +787,26 @@ class CourseMauLiveMetricsSerializer(serializers.Serializer):
count = serializers.IntegerField()
course_id = serializers.CharField()
domain = serializers.CharField()


class EnrollmentMetricsSerializer(serializers.ModelSerializer):
"""Serializer for LearnerCourseGradeMetrics
"""
user = UserIndexSerializer(read_only=True)
progress_percent = serializers.DecimalField(max_digits=3,
decimal_places=2,
min_value=0.00,
max_value=1.00)

class Meta:
model = LearnerCourseGradeMetrics
editable = False
fields = ('id', 'site_id', 'user', 'course_id', 'date_for', 'completed',
'points_earned', 'points_possible',
'sections_worked', 'sections_possible',
'progress_percent')


class CourseCompletedSerializer(serializers.Serializer):
course_id = serializers.CharField()
user_id = serializers.IntegerField()
6 changes: 6 additions & 0 deletions figures/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@
views.UserIndexViewSet,
base_name='user-index')

# Experimental

router.register(
r'enrollment-metrics',
views.EnrollmentMetricsViewSet,
base_name='enrollment-metrics')

urlpatterns = [

Expand Down
67 changes: 62 additions & 5 deletions figures/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
CourseEnrollmentFilter,
CourseMauMetricsFilter,
CourseOverviewFilter,
EnrollmentMetricsFilter,
SiteDailyMetricsFilter,
SiteFilterSet,
SiteMauMetricsFilter,
Expand All @@ -48,16 +49,19 @@
from figures.models import (
CourseDailyMetrics,
CourseMauMetrics,
LearnerCourseGradeMetrics,
SiteDailyMetrics,
SiteMauMetrics,
)
from figures.serializers import (
CourseCompletedSerializer,
CourseDailyMetricsSerializer,
CourseDetailsSerializer,
CourseEnrollmentSerializer,
CourseIndexSerializer,
CourseMauMetricsSerializer,
CourseMauLiveMetricsSerializer,
EnrollmentMetricsSerializer,
GeneralCourseDataSerializer,
LearnerDetailsSerializer,
SiteDailyMetricsSerializer,
Expand Down Expand Up @@ -394,6 +398,64 @@ def get_serializer_context(self):
return context


class EnrollmentMetricsViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet):
"""Initial viewset for enrollment metrics
Initial purpose to serve up course progress and completion data
Because we need to test performance, we want to keep completion data
isolated from other API data
"""
model = LearnerCourseGradeMetrics
pagination_class = FiguresLimitOffsetPagination
serializer_class = EnrollmentMetricsSerializer
filter_backends = (DjangoFilterBackend, )
# Assess updating to "EnrollmentFilterSet" to filter on list of courses and
# or users, so we can use it to build a filterable table of users, courses
filter_class = EnrollmentMetricsFilter

def get_queryset(self):
site = django.contrib.sites.shortcuts.get_current_site(self.request)
queryset = LearnerCourseGradeMetrics.objects.filter(site=site)
return queryset

@list_route()
def completed_ids(self, request, *args, **kwargs):
"""Return distinct course id/user id pairs for completed enrollments
Endpoint is `/figures/api/enrollment-metrics/completed_ids/`
The default router does not support hyphen in the custom action, so
we need to use the underscore until we implement a custom router
"""
site = django.contrib.sites.shortcuts.get_current_site(self.request)
qs = self.model.objects.completed_ids_for_site(site=site)
page = self.paginate_queryset(qs)
if page is not None:
serializer = CourseCompletedSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = CourseCompletedSerializer(qs, many=True)
return Response(serializer.data)

@list_route()
def completed(self, reqwuest, *args, **kwargs):
"""Experimental endpoint to return completed LCGM records
This is the same as `/figures/api/enrollment-metrics/?only_completed=True
Return matching LearnerCourseGradeMetric rows that have completed
enrollments
"""
site = django.contrib.sites.shortcuts.get_current_site(self.request)
qs = self.model.objects.completed_for_site(site=site)
page = self.paginate_queryset(qs)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data)


class CourseMonthlyMetricsViewSet(CommonAuthMixin, viewsets.ViewSet):
"""
Expand Down Expand Up @@ -635,11 +697,6 @@ def active_users(self, request):
return Response(dict(active_users=active_users))


#
# MAU metrics views
#


class CourseMauLiveMetricsViewSet(CommonAuthMixin, viewsets.GenericViewSet):
serializer_class = CourseMauLiveMetricsSerializer

Expand Down
6 changes: 6 additions & 0 deletions tests/views/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ def create_test_users():
UserFactory(username='super_user', is_superuser=True),
UserFactory(username='superstaff_user', is_staff=True, is_superuser=True)
]


def assert_paginated(response_data):
"""Assert the response data dict has expected paginated results keys
"""
assert set(response_data.keys()) == set([u'count', u'next', u'previous', u'results'])
Loading

0 comments on commit a32602e

Please sign in to comment.