Skip to content

Commit

Permalink
Learner metrics endpoint - Initial commit
Browse files Browse the repository at this point in the history
This is the initial commit so Matej and work on the front end

The endpoint is `/figures/api/learner-metrics/`

* There is a basic viewset just to exercise the code. The test requires
test data to be filled out and tested in the response

* UserFilterSet needs to be updated or an alternate filter set needs to
be used in order to provide more filtering, in particular
 * Show only users who have enrollments
 * Show only users who do not have enrollments
 * Show only users who have completed
 * Show only users who have not completed

* List serializers need to be added to prefetch data to improve API
performance
* test_learner_metrics_viewset needs to be completed
* Updated the CourseEnrollment mock to provide the `is_enrolled` method
  • Loading branch information
johnbaldwin committed Aug 1, 2020
1 parent 33e61a3 commit 651c5f3
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 2 deletions.
73 changes: 73 additions & 0 deletions figures/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"""

import datetime
from decimal import Decimal

from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
Expand Down Expand Up @@ -791,6 +792,10 @@ class CourseMauLiveMetricsSerializer(serializers.Serializer):

class EnrollmentMetricsSerializer(serializers.ModelSerializer):
"""Serializer for LearnerCourseGradeMetrics
This is a prototype serializer for exploring API endpoints
It provides an enrollment major, use minor view
"""
user = UserIndexSerializer(read_only=True)
progress_percent = serializers.DecimalField(max_digits=3,
Expand All @@ -808,5 +813,73 @@ class Meta:


class CourseCompletedSerializer(serializers.Serializer):
"""Provides course id and user id for course completions
This serializer is used in the `enrollment-metrics` endpoint
"""
course_id = serializers.CharField()
user_id = serializers.IntegerField()


class EnrollmentMetricsSerializerV2(serializers.ModelSerializer):
"""Provides serialization for an enrollment
This serializer note not identify the learner. It is used in
LearnerMetricsSerializer
"""
course_id = serializers.CharField()
date_enrolled = serializers.DateTimeField(source='created',
format="%Y-%m-%d")
is_enrolled = serializers.BooleanField()
progress_percent = serializers.SerializerMethodField()
progress_details = serializers.SerializerMethodField()

class Meta:
model = CourseEnrollment
fields = ['id', 'course_id', 'date_enrolled', 'is_enrolled',
'progress_percent', 'progress_details']
read_only_fields = fields

def to_representation(self, instance):
"""
Get the most recent LCGM record for the enrollment, if it exists
"""
self._lcgm = LearnerCourseGradeMetrics.objects.most_recent_for_learner_course(
user=instance.user, course_id=str(instance.course_id))
return super(EnrollmentMetricsSerializerV2, self).to_representation(instance)

def get_is_enrolled(self, obj):
"""
CourseEnrollment has to do some work to get this value
TODO: inspect CourseEnrollment._get_enrollment_state to see how we
can speed this up, avoiding construction of `CourseEnrollmentState`
"""
return CourseEnrollment.is_enrolled(obj.user, obj.course_id)

def get_progress_percent(self, obj):
value = self._lcgm.progress_percent if self._lcgm else 0
return float(Decimal(value).quantize(Decimal('.00')))

def get_progress_details(self, obj):
"""Get progress data for a single enrollment
"""
return self._lcgm.progress_details if self._lcgm else None


class LearnerMetricsSerializer(serializers.ModelSerializer):
fullname = serializers.CharField(source='profile.name', default=None)
# enrollments = EnrollmentMetricsSerializerV2(source='courseenrollment_set',
# many=True)
enrollments = serializers.SerializerMethodField()

class Meta:
model = get_user_model()
fields = ('id', 'username', 'email', 'fullname', 'is_active',
'date_joined', 'enrollments')
read_only_fields = fields

def get_enrollments(self, user):
site_enrollments = figures.sites.get_course_enrollments_for_site(
self.context.get('site'))
user_enrollments = site_enrollments.filter(user=user)
return EnrollmentMetricsSerializerV2(user_enrollments, many=True).data
10 changes: 9 additions & 1 deletion figures/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,21 @@
views.UserIndexViewSet,
base_name='user-index')

# Experimental

# New endpoints in development (unstable)
# Unstable here means the code is subject to change without notice

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

router.register(
r'learner-metrics',
views.LearnerMetricsViewSet,
base_name='learner-metrics')


urlpatterns = [

# UI Templates
Expand Down
30 changes: 30 additions & 0 deletions figures/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
EnrollmentMetricsSerializer,
GeneralCourseDataSerializer,
LearnerDetailsSerializer,
LearnerMetricsSerializer,
SiteDailyMetricsSerializer,
SiteMauMetricsSerializer,
SiteMauLiveMetricsSerializer,
Expand Down Expand Up @@ -398,6 +399,35 @@ def get_serializer_context(self):
return context


class LearnerMetricsViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet):
"""Provides user identity and nested enrollment data
This view is unders active development and subject to change
TODO: After we get this class tests running, restructure this module:
* Group all user model based viewsets together
* Make a base user viewset with the `get_queryset` and `get_serializer_context`
methods
"""
model = get_user_model()
pagination_class = FiguresLimitOffsetPagination
serializer_class = LearnerMetricsSerializer
filter_backends = (DjangoFilterBackend, )

# TODO: Improve this filter
filter_class = UserFilterSet

def get_queryset(self):
site = django.contrib.sites.shortcuts.get_current_site(self.request)
queryset = figures.sites.get_users_for_site(site)
return queryset

def get_serializer_context(self):
context = super(LearnerMetricsViewSet, self).get_serializer_context()
context['site'] = django.contrib.sites.shortcuts.get_current_site(self.request)
return context


class EnrollmentMetricsViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet):
"""Initial viewset for enrollment metrics
Expand Down
44 changes: 43 additions & 1 deletion mocks/hawthorn/student/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

from collections import defaultdict
from collections import defaultdict, namedtuple
from datetime import datetime

from pytz import UTC
Expand Down Expand Up @@ -129,6 +129,12 @@ def enrollment_counts(self, course_id):
return enroll_dict


# Named tuple for fields pertaining to the state of
# CourseEnrollment for a user in a course. This type
# is used to cache the state in the request cache.
CourseEnrollmentState = namedtuple('CourseEnrollmentState', 'mode, is_active')


class CourseEnrollment(models.Model):
'''
The production model is student.models.CourseEnrollment
Expand Down Expand Up @@ -191,6 +197,42 @@ def __init__(self, *args, **kwargs):
# When the property .course_overview is accessed for the first time, this variable will be set.
self._course_overview = None

@classmethod
def is_enrolled(cls, user, course_key):
"""
Returns True if the user is enrolled in the course (the entry must exist
and it must have `is_active=True`). Otherwise, returns False.
`user` is a Django User object. If it hasn't been saved yet (no `.id`
attribute), this method will automatically save it before
adding an enrollment for it.
`course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
"""
enrollment_state = cls._get_enrollment_state(user, course_key)
return enrollment_state.is_active or False

@classmethod
def _get_enrollment_state(cls, user, course_key):
"""
Returns the CourseEnrollmentState for the given user
and course_key, caching the result for later retrieval.
Figures note: removed the caching after copying this method
"""
assert user

if user.is_anonymous:
return CourseEnrollmentState(None, None)

try:
record = cls.objects.get(user=user, course_id=course_key)
enrollment_state = CourseEnrollmentState(record.mode, record.is_active)
except cls.DoesNotExist:
enrollment_state = CourseEnrollmentState(None, None)

return enrollment_state


class CourseAccessRole(models.Model):
user = models.ForeignKey(User)
Expand Down
156 changes: 156 additions & 0 deletions tests/views/test_learner_metrics_viewset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Tests Figures learner-metrics viewset
"""

import pytest

import django.contrib.sites.shortcuts
from rest_framework import status
from rest_framework.test import APIRequestFactory, force_authenticate

from figures.views import LearnerMetricsViewSet

from tests.factories import (
CourseEnrollmentFactory,
CourseOverviewFactory,
# LearnerCourseGradeMetricsFactory,
OrganizationFactory,
SiteFactory,
UserFactory,
)

from tests.helpers import organizations_support_sites
from tests.views.base import BaseViewTest
from tests.views.helpers import is_response_paginated

if organizations_support_sites():
from tests.factories import UserOrganizationMappingFactory

def map_users_to_org_site(caller, site, users):
org = OrganizationFactory(sites=[site])
UserOrganizationMappingFactory(user=caller,
organization=org,
is_amc_admin=True)
[UserOrganizationMappingFactory(user=user,
organization=org) for user in users]
# return created objects that the test will need
return caller


@pytest.fixture
def enrollment_test_data():
"""Stands up shared test data. We need to revisit this
"""
num_courses = 2
site = SiteFactory()
course_overviews = [CourseOverviewFactory() for i in range(num_courses)]
# Create a number of enrollments for each course
enrollments = []
for num_enroll, co in enumerate(course_overviews, 1):
enrollments += [CourseEnrollmentFactory(
course_id=co.id) for i in range(num_enroll)]

# This is a convenience for the test method
users = [enrollment.user for enrollment in enrollments]
return dict(
site=site,
course_overviews=course_overviews,
enrollments=enrollments,
users=users,
)


@pytest.mark.django_db
class TestLearnerMetricsViewSet(BaseViewTest):
"""Tests the learner metrics viewset
The tests are incomplete
The list action will return a list of the following records:
```
{
"id": 109,
"username": "chasecynthia",
"email": "[email protected]",
"fullname": "Brandon Meyers",
"is_active": true,
"date_joined": "2020-06-03T00:00:00Z",
"enrollments": [
{
"id": 9,
"course_id": "course-v1:StarFleetAcademy+SFA01+2161",
"date_enrolled": "2020-02-24",
"is_enrolled": true,
"progress_percent": 1.0,
"progress_details": {
"sections_worked": 20,
"points_possible": 100.0,
"sections_possible": 20,
"points_earned": 50.0
}
}
]
}
```
"""
base_request_path = 'api/learner-metrics/'
view_class = LearnerMetricsViewSet

@pytest.fixture(autouse=True)
def setup(self, db, settings):
if organizations_support_sites():
settings.FEATURES['FIGURES_IS_MULTISITE'] = True
super(TestLearnerMetricsViewSet, self).setup(db)

def make_caller(self, site, users):
"""Convenience method to create the API caller user
"""
if organizations_support_sites():
# TODO: set is_staff to False after we have test coverage
caller = UserFactory(is_staff=True)
map_users_to_org_site(caller=caller, site=site, users=users)
else:
caller = UserFactory(is_staff=True)
return caller

def make_request(self, monkeypatch, request_path, site, caller, action):
"""Convenience method to make the API request
Returns the response object
"""
request = APIRequestFactory().get(request_path)
request.META['HTTP_HOST'] = site.domain
monkeypatch.setattr(django.contrib.sites.shortcuts,
'get_current_site',
lambda req: site)
force_authenticate(request, user=caller)
view = self.view_class.as_view({'get': action})
return view(request)

def test_list_method_all(self, monkeypatch, enrollment_test_data):
"""INCOMPLETE TEST
"""
site = enrollment_test_data['site']
users = enrollment_test_data['users']
enrollments = enrollment_test_data['enrollments']

caller = self.make_caller(site, users)
other_site = SiteFactory()
assert site.domain != other_site.domain

response = self.make_request(request_path=self.base_request_path,
monkeypatch=monkeypatch,
site=site,
caller=caller,
action='list')

assert response.status_code == status.HTTP_200_OK
assert is_response_paginated(response.data)
results = response.data['results']
# Check user ids
result_ids = [obj['id'] for obj in results]
assert set(result_ids) == set([obj.user_id for obj in enrollments]+[caller.id])
# Spot check the first record
top_keys = ['id', 'username', 'email', 'fullname', 'is_active',
'date_joined', 'enrollments']
assert set(results[0].keys()) == set(top_keys)

0 comments on commit 651c5f3

Please sign in to comment.